From 95898e51fda27d04df9df361cc7588183d3d5d4b Mon Sep 17 00:00:00 2001 From: Michael Nabil Date: Sat, 12 Jul 2025 18:12:31 +0300 Subject: [PATCH 1/4] Add support for resolving props once in Inertia middleware and response --- src/Inertia.php | 1 + src/Middleware.php | 14 ++++++++++++++ src/Response.php | 17 ++++++++++++++++- src/ResponseFactory.php | 11 ++++++++++- stubs/middleware.stub | 12 ++++++++++++ 5 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/Inertia.php b/src/Inertia.php index 1d0adb38..2b7d00f2 100644 --- a/src/Inertia.php +++ b/src/Inertia.php @@ -14,6 +14,7 @@ * @method static void version(\Closure|string|null $version) * @method static string getVersion() * @method static void resolveUrlUsing(\Closure|null $urlResolver = null) + * @method static void resolveOncePropsUsing(\Closure|null $urlResolver = null) * @method static \Inertia\OptionalProp optional(callable $callback) * @method static \Inertia\LazyProp lazy(callable $callback) * @method static \Inertia\DeferProp defer(callable $callback, string $group = 'default') diff --git a/src/Middleware.php b/src/Middleware.php index 482a99d5..52803a38 100644 --- a/src/Middleware.php +++ b/src/Middleware.php @@ -79,6 +79,16 @@ public function urlResolver() return null; } + /** + * Defines the props that are shared for one time. + * + * @return Closure|null + */ + public function oncePropsResolver() + { + return null; + } + /** * Handle the incoming request. * @@ -97,6 +107,10 @@ public function handle(Request $request, Closure $next) Inertia::resolveUrlUsing($urlResolver); } + if ($oncePropsResolver = $this->oncePropsResolver()) { + Inertia::resolveOncePropsUsing($oncePropsResolver); + } + $response = $next($request); $response->headers->set('Vary', Header::INERTIA); diff --git a/src/Response.php b/src/Response.php index b5f8ec82..1eb8554e 100644 --- a/src/Response.php +++ b/src/Response.php @@ -38,6 +38,8 @@ class Response implements Responsable protected ?Closure $urlResolver = null; + protected ?Closure $oncePropsResolver = null; + /** * @param array|Arrayable $props */ @@ -47,7 +49,8 @@ public function __construct( string $rootView = 'app', string $version = '', bool $encryptHistory = false, - ?Closure $urlResolver = null + ?Closure $urlResolver = null, + ?Closure $oncePropsResolver = null, ) { $this->component = $component; $this->props = $props instanceof Arrayable ? $props->toArray() : $props; @@ -56,6 +59,7 @@ public function __construct( $this->clearHistory = session()->pull('inertia.clear_history', false); $this->encryptHistory = $encryptHistory; $this->urlResolver = $urlResolver; + $this->oncePropsResolver = $oncePropsResolver; } /** @@ -132,6 +136,8 @@ public function toResponse($request) return new JsonResponse($page, 200, [Header::INERTIA => 'true']); } + $page += $this->resolveOnceProps($request); + return ResponseFactory::view($this->rootView, $this->viewData + ['page' => $page]); } @@ -376,6 +382,15 @@ public function resolveDeferredProps(Request $request): array return $deferredProps->isNotEmpty() ? ['deferredProps' => $deferredProps->toArray()] : []; } + public function resolveOnceProps(Request $request): array + { + $onceProps = $this->oncePropsResolver + ? App::call($this->oncePropsResolver, ['request' => $request]) + : []; + + return ['onceProps' => $onceProps]; + } + /** * Determine if the request is a partial request. */ diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php index d6ed86b2..4865dbb5 100644 --- a/src/ResponseFactory.php +++ b/src/ResponseFactory.php @@ -35,7 +35,10 @@ class ResponseFactory /** @var Closure|null */ protected $urlResolver; - /*** + /** @var Closure|null */ + protected $oncePropsResolver; + + /** * @param string $name The name of the root view * @return void */ @@ -102,6 +105,11 @@ public function resolveUrlUsing(?Closure $urlResolver = null): void $this->urlResolver = $urlResolver; } + public function resolveOncePropsUsing(?Closure $oncePropsResolver = null): void + { + $this->oncePropsResolver = $oncePropsResolver; + } + public function clearHistory(): void { session(['inertia.clear_history' => true]); @@ -189,6 +197,7 @@ public function render(string $component, $props = []): Response $this->getVersion(), $this->encryptHistory ?? config('inertia.history.encrypt', false), $this->urlResolver, + $this->oncePropsResolver, ); } diff --git a/stubs/middleware.stub b/stubs/middleware.stub index 09f6b641..52997111 100644 --- a/stubs/middleware.stub +++ b/stubs/middleware.stub @@ -40,4 +40,16 @@ class {{ class }} extends Middleware // ]; } + + /** + * Defines the props that are shared for one time. + * + * @return Closure + */ + public function oncePropsResolver() + { + return function (Request $request): array { + return []; + }; + } } From 04d84a39563f36cbf565a73c0703ed944bff9528 Mon Sep 17 00:00:00 2001 From: Michael Nabil Date: Sat, 12 Jul 2025 18:16:08 +0300 Subject: [PATCH 2/4] Add test --- src/Response.php | 2 +- tests/MiddlewareTest.php | 14 +++++++++++ .../CustomOncePropsResolverMiddleware.php | 24 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 tests/Stubs/CustomOncePropsResolverMiddleware.php diff --git a/src/Response.php b/src/Response.php index 1eb8554e..74b0e144 100644 --- a/src/Response.php +++ b/src/Response.php @@ -388,7 +388,7 @@ public function resolveOnceProps(Request $request): array ? App::call($this->oncePropsResolver, ['request' => $request]) : []; - return ['onceProps' => $onceProps]; + return empty($onceProps) ? [] : ['onceProps' => $onceProps]; } /** diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php index 75102a66..0551f3a0 100644 --- a/tests/MiddlewareTest.php +++ b/tests/MiddlewareTest.php @@ -12,6 +12,7 @@ use Inertia\AlwaysProp; use Inertia\Inertia; use Inertia\Middleware; +use Inertia\Tests\Stubs\CustomOncePropsResolverMiddleware; use Inertia\Tests\Stubs\CustomUrlResolverMiddleware; use Inertia\Tests\Stubs\ExampleMiddleware; use LogicException; @@ -146,6 +147,19 @@ public function test_the_url_can_be_resolved_with_a_custom_resolver() ]); } + public function test_the_once_props_can_be_resolved_with_a_custom_resolver() + { + $this->prepareMockEndpoint(middleware: new CustomOncePropsResolverMiddleware); + + $response = $this->withoutExceptionHandling()->get('/'); + + $response->assertSuccessful(); + $this->assertSame( + '
', + $response->content(), + ); + } + public function test_validation_errors_are_registered_as_of_default(): void { Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { diff --git a/tests/Stubs/CustomOncePropsResolverMiddleware.php b/tests/Stubs/CustomOncePropsResolverMiddleware.php new file mode 100644 index 00000000..dbce0ce7 --- /dev/null +++ b/tests/Stubs/CustomOncePropsResolverMiddleware.php @@ -0,0 +1,24 @@ + true, + 'appName' => 'test', + ]; + }; + } +} From bfae8615dddc57cc0285a5874829eb65cc4c8160 Mon Sep 17 00:00:00 2001 From: Michael Nabil Date: Tue, 15 Jul 2025 14:18:51 +0300 Subject: [PATCH 3/4] Introduce `InitialProp` class --- src/FirstLoad.php | 8 ++ src/Inertia.php | 1 - src/InitialProp.php | 20 +++++ src/Middleware.php | 14 ---- src/Response.php | 30 +++++--- src/ResponseFactory.php | 17 ++--- stubs/middleware.stub | 12 --- tests/InitialPropTest.php | 73 +++++++++++++++++++ tests/MiddlewareTest.php | 14 ---- tests/ResponseFactoryTest.php | 11 +++ .../CustomOncePropsResolverMiddleware.php | 24 ------ 11 files changed, 137 insertions(+), 87 deletions(-) create mode 100644 src/FirstLoad.php create mode 100644 src/InitialProp.php create mode 100644 tests/InitialPropTest.php delete mode 100644 tests/Stubs/CustomOncePropsResolverMiddleware.php diff --git a/src/FirstLoad.php b/src/FirstLoad.php new file mode 100644 index 00000000..a1a1eac9 --- /dev/null +++ b/src/FirstLoad.php @@ -0,0 +1,8 @@ +callback = $callback; + } + + public function __invoke() + { + return App::call($this->callback); + } +} diff --git a/src/Middleware.php b/src/Middleware.php index 52803a38..482a99d5 100644 --- a/src/Middleware.php +++ b/src/Middleware.php @@ -79,16 +79,6 @@ public function urlResolver() return null; } - /** - * Defines the props that are shared for one time. - * - * @return Closure|null - */ - public function oncePropsResolver() - { - return null; - } - /** * Handle the incoming request. * @@ -107,10 +97,6 @@ public function handle(Request $request, Closure $next) Inertia::resolveUrlUsing($urlResolver); } - if ($oncePropsResolver = $this->oncePropsResolver()) { - Inertia::resolveOncePropsUsing($oncePropsResolver); - } - $response = $next($request); $response->headers->set('Vary', Header::INERTIA); diff --git a/src/Response.php b/src/Response.php index 74b0e144..2659ae12 100644 --- a/src/Response.php +++ b/src/Response.php @@ -38,8 +38,6 @@ class Response implements Responsable protected ?Closure $urlResolver = null; - protected ?Closure $oncePropsResolver = null; - /** * @param array|Arrayable $props */ @@ -50,7 +48,6 @@ public function __construct( string $version = '', bool $encryptHistory = false, ?Closure $urlResolver = null, - ?Closure $oncePropsResolver = null, ) { $this->component = $component; $this->props = $props instanceof Arrayable ? $props->toArray() : $props; @@ -59,7 +56,6 @@ public function __construct( $this->clearHistory = session()->pull('inertia.clear_history', false); $this->encryptHistory = $encryptHistory; $this->urlResolver = $urlResolver; - $this->oncePropsResolver = $oncePropsResolver; } /** @@ -130,14 +126,13 @@ public function toResponse($request) $this->resolveMergeProps($request), $this->resolveDeferredProps($request), $this->resolveCacheDirections($request), + $this->resolveInitialProps($request), ); if ($request->header(Header::INERTIA)) { return new JsonResponse($page, 200, [Header::INERTIA => 'true']); } - $page += $this->resolveOnceProps($request); - return ResponseFactory::view($this->rootView, $this->viewData + ['page' => $page]); } @@ -150,6 +145,7 @@ public function resolveProperties(Request $request, array $props): array $props = $this->resolveArrayableProperties($props, $request); $props = $this->resolveAlways($props); $props = $this->resolvePropertyInstances($props, $request); + $props = $this->removeInitialProperties($props); return $props; } @@ -302,6 +298,14 @@ public function resolvePropertyInstances(array $props, Request $request): array return $props; } + /** + * Remove initial properties from the response. + */ + public function removeInitialProperties(array $props): array + { + return array_filter($props, static fn ($prop) => ! $prop instanceof InitialProp); + } + /** * Resolve the cache directions for the response. */ @@ -382,13 +386,17 @@ public function resolveDeferredProps(Request $request): array return $deferredProps->isNotEmpty() ? ['deferredProps' => $deferredProps->toArray()] : []; } - public function resolveOnceProps(Request $request): array + public function resolveInitialProps(Request $request): array { - $onceProps = $this->oncePropsResolver - ? App::call($this->oncePropsResolver, ['request' => $request]) - : []; + if ($request->header(Header::INERTIA)) { + return []; + } + + $initialProps = collect($this->props) + ->filter(fn ($prop) => $prop instanceof InitialProp) + ->mapWithKeys(fn ($value, $key) => [$key => App::call($value)]); - return empty($onceProps) ? [] : ['onceProps' => $onceProps]; + return $initialProps->isNotEmpty() ? ['initialProps' => $initialProps->toArray()] : []; } /** diff --git a/src/ResponseFactory.php b/src/ResponseFactory.php index 4865dbb5..3d609f35 100644 --- a/src/ResponseFactory.php +++ b/src/ResponseFactory.php @@ -35,12 +35,8 @@ class ResponseFactory /** @var Closure|null */ protected $urlResolver; - /** @var Closure|null */ - protected $oncePropsResolver; - /** - * @param string $name The name of the root view - * @return void + * @param string $name The name of the root view */ public function setRootView(string $name): void { @@ -105,11 +101,6 @@ public function resolveUrlUsing(?Closure $urlResolver = null): void $this->urlResolver = $urlResolver; } - public function resolveOncePropsUsing(?Closure $oncePropsResolver = null): void - { - $this->oncePropsResolver = $oncePropsResolver; - } - public function clearHistory(): void { session(['inertia.clear_history' => true]); @@ -136,6 +127,11 @@ public function optional(callable $callback): OptionalProp return new OptionalProp($callback); } + public function initial(callable $callback): InitialProp + { + return new InitialProp($callback); + } + public function defer(callable $callback, string $group = 'default'): DeferProp { return new DeferProp($callback, $group); @@ -197,7 +193,6 @@ public function render(string $component, $props = []): Response $this->getVersion(), $this->encryptHistory ?? config('inertia.history.encrypt', false), $this->urlResolver, - $this->oncePropsResolver, ); } diff --git a/stubs/middleware.stub b/stubs/middleware.stub index 52997111..09f6b641 100644 --- a/stubs/middleware.stub +++ b/stubs/middleware.stub @@ -40,16 +40,4 @@ class {{ class }} extends Middleware // ]; } - - /** - * Defines the props that are shared for one time. - * - * @return Closure - */ - public function oncePropsResolver() - { - return function (Request $request): array { - return []; - }; - } } diff --git a/tests/InitialPropTest.php b/tests/InitialPropTest.php new file mode 100644 index 00000000..2dbdabfd --- /dev/null +++ b/tests/InitialPropTest.php @@ -0,0 +1,73 @@ + Inertia::initial(fn () => true), + 'appName' => Inertia::initial(fn () => 'test'), + ]); + + $this->prepareMockEndpoint(); + + $response = $this->withoutExceptionHandling()->get('/'); + + $response->assertSuccessful(); + $this->assertSame( + '
', + $response->content(), + ); + } + + public function test_initial_props_are_not_accessible() + { + Inertia::share([ + 'initial' => Inertia::initial(fn () => true), + 'appName' => Inertia::initial(fn () => 'test'), + ]); + + $this->prepareMockEndpoint(); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJsonMissingPath('initialProps'); + } + + public function test_can_invoke(): void + { + $initialProp = new InitialProp(function () { + return 'A initial value'; + }); + + $this->assertSame('A initial value', $initialProp()); + } + + public function test_can_resolve_bindings_when_invoked(): void + { + $initialProp = new InitialProp(function (Request $request) { + return $request; + }); + + $this->assertInstanceOf(Request::class, $initialProp()); + } + + private function prepareMockEndpoint(): \Illuminate\Routing\Route + { + return Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit'); + }); + } +} diff --git a/tests/MiddlewareTest.php b/tests/MiddlewareTest.php index 0551f3a0..75102a66 100644 --- a/tests/MiddlewareTest.php +++ b/tests/MiddlewareTest.php @@ -12,7 +12,6 @@ use Inertia\AlwaysProp; use Inertia\Inertia; use Inertia\Middleware; -use Inertia\Tests\Stubs\CustomOncePropsResolverMiddleware; use Inertia\Tests\Stubs\CustomUrlResolverMiddleware; use Inertia\Tests\Stubs\ExampleMiddleware; use LogicException; @@ -147,19 +146,6 @@ public function test_the_url_can_be_resolved_with_a_custom_resolver() ]); } - public function test_the_once_props_can_be_resolved_with_a_custom_resolver() - { - $this->prepareMockEndpoint(middleware: new CustomOncePropsResolverMiddleware); - - $response = $this->withoutExceptionHandling()->get('/'); - - $response->assertSuccessful(); - $this->assertSame( - '
', - $response->content(), - ); - } - public function test_validation_errors_are_registered_as_of_default(): void { Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { diff --git a/tests/ResponseFactoryTest.php b/tests/ResponseFactoryTest.php index 52970279..1048bbe6 100644 --- a/tests/ResponseFactoryTest.php +++ b/tests/ResponseFactoryTest.php @@ -15,6 +15,7 @@ use Inertia\ComponentNotFoundException; use Inertia\DeferProp; use Inertia\Inertia; +use Inertia\InitialProp; use Inertia\LazyProp; use Inertia\MergeProp; use Inertia\OptionalProp; @@ -342,6 +343,16 @@ public function test_can_create_optional_prop(): void $this->assertInstanceOf(OptionalProp::class, $optionalProp); } + public function test_can_create_initial_prop(): void + { + $factory = new ResponseFactory; + $initialProp = $factory->initial(function () { + return 'An initial value'; + }); + + $this->assertInstanceOf(InitialProp::class, $initialProp); + } + public function test_can_create_always_prop(): void { $factory = new ResponseFactory; diff --git a/tests/Stubs/CustomOncePropsResolverMiddleware.php b/tests/Stubs/CustomOncePropsResolverMiddleware.php deleted file mode 100644 index dbce0ce7..00000000 --- a/tests/Stubs/CustomOncePropsResolverMiddleware.php +++ /dev/null @@ -1,24 +0,0 @@ - true, - 'appName' => 'test', - ]; - }; - } -} From bfbaa7599e7ae54cf81c767edd3cbf140b89c84e Mon Sep 17 00:00:00 2001 From: Michael Nabil Date: Tue, 15 Jul 2025 14:48:20 +0300 Subject: [PATCH 4/4] Refactor property resolution to filter initial properties and rename method for clarity --- src/Response.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Response.php b/src/Response.php index 2659ae12..3228043f 100644 --- a/src/Response.php +++ b/src/Response.php @@ -142,10 +142,10 @@ public function toResponse($request) public function resolveProperties(Request $request, array $props): array { $props = $this->resolvePartialProperties($props, $request); + $props = $this->filterInitialProps($props); $props = $this->resolveArrayableProperties($props, $request); $props = $this->resolveAlways($props); $props = $this->resolvePropertyInstances($props, $request); - $props = $this->removeInitialProperties($props); return $props; } @@ -299,9 +299,9 @@ public function resolvePropertyInstances(array $props, Request $request): array } /** - * Remove initial properties from the response. + * Filter initial properties from the props. */ - public function removeInitialProperties(array $props): array + public function filterInitialProps(array $props): array { return array_filter($props, static fn ($prop) => ! $prop instanceof InitialProp); }