From 3e7afc2a4760ac1e431aedd3bb90a85146526a54 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:57:54 +0000 Subject: [PATCH 01/83] Add getMiddlewareGroups() to HTTP Kernel contract Packages like Inertia need to inspect middleware groups to resolve the correct middleware for exception rendering. The method exists on the concrete Kernel but was missing from the contract. --- src/contracts/src/Http/Kernel.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/contracts/src/Http/Kernel.php b/src/contracts/src/Http/Kernel.php index 1eeae3a68..9ca6453aa 100644 --- a/src/contracts/src/Http/Kernel.php +++ b/src/contracts/src/Http/Kernel.php @@ -25,6 +25,13 @@ public function handle(Request $request): Response; */ public function terminate(Request $request, Response $response): void; + /** + * Get the application's route middleware groups. + * + * @return array> + */ + public function getMiddlewareGroups(): array; + /** * Get the application instance. */ From 1c12b70533235528ccac8a8834e4f48435872f04 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:58:09 +0000 Subject: [PATCH 02/83] Fix BladeCompiler::directive() to accept callable The parameter was incorrectly narrowed to Closure. Laravel accepts any callable, and packages like Inertia register directives using static method arrays like [Directive::class, 'compile']. --- src/support/src/Facades/Blade.php | 2 +- src/view/src/Compilers/BladeCompiler.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/support/src/Facades/Blade.php b/src/support/src/Facades/Blade.php index c7a9a641c..06ecdca12 100644 --- a/src/support/src/Facades/Blade.php +++ b/src/support/src/Facades/Blade.php @@ -27,7 +27,7 @@ * @method static void include(string $path, string|null $alias = null) * @method static void aliasInclude(string $path, string|null $alias = null) * @method static void bindDirective(string $name, callable $handler) - * @method static void directive(string $name, \Closure $handler, bool $bind = false) + * @method static void directive(string $name, callable $handler, bool $bind = false) * @method static array getCustomDirectives() * @method static \Hypervel\View\Compilers\BladeCompiler prepareStringsForCompilationUsing(callable $callback) * @method static void precompiler(callable $precompiler) diff --git a/src/view/src/Compilers/BladeCompiler.php b/src/view/src/Compilers/BladeCompiler.php index b165ab702..7cb1b5196 100644 --- a/src/view/src/Compilers/BladeCompiler.php +++ b/src/view/src/Compilers/BladeCompiler.php @@ -802,13 +802,13 @@ public function bindDirective(string $name, callable $handler): void * * @throws InvalidArgumentException */ - public function directive(string $name, Closure $handler, bool $bind = false): void + public function directive(string $name, callable $handler, bool $bind = false): void { if (! preg_match('/^\w+(?:::\w+)?$/x', $name)) { throw new InvalidArgumentException("The directive name [{$name}] is not valid. Directive names must only contain alphanumeric characters and underscores."); } - $this->customDirectives[$name] = $bind ? $handler->bindTo($this, BladeCompiler::class) : $handler; + $this->customDirectives[$name] = $bind ? Closure::fromCallable($handler)->bindTo($this, self::class) : $handler; } /** From 89fac007c7dc900c4f5b5aea54ffef26a04a3c9d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:58:18 +0000 Subject: [PATCH 03/83] Fix HTTP client Response JSON decode caching The decoded JSON cache used a truthy check and array type, which broke for valid falsy values (empty arrays, 0, false) and scalar JSON responses. Changed to mixed property with explicit boolean flag to track decode state. --- src/http/src/Client/Response.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/http/src/Client/Response.php b/src/http/src/Client/Response.php index 5be8af688..b9a247449 100644 --- a/src/http/src/Client/Response.php +++ b/src/http/src/Client/Response.php @@ -31,7 +31,12 @@ class Response implements ArrayAccess, Stringable /** * The decoded JSON response. */ - protected array $decoded = []; + protected mixed $decoded = null; + + /** + * Whether the response body has been decoded. + */ + protected bool $hasDecoded = false; /** * The custom decode callback. @@ -73,8 +78,9 @@ public function body(): string */ public function json(?string $key = null, mixed $default = null): mixed { - if (! $this->decoded) { + if (! $this->hasDecoded) { $this->decoded = $this->decode($this->body()); + $this->hasDecoded = true; } if (is_null($key)) { @@ -111,7 +117,8 @@ public function object(): array|object|null public function decodeUsing(?Closure $callback): static { $this->decodeUsing = $callback; - $this->decoded = []; + $this->decoded = null; + $this->hasDecoded = false; return $this; } @@ -119,7 +126,7 @@ public function decodeUsing(?Closure $callback): static /** * Decode the given response body. */ - protected function decode(string $body, bool $asObject = false): array|object|null + protected function decode(string $body, bool $asObject = false): mixed { if ($this->decodeUsing instanceof Closure) { return ($this->decodeUsing)($body, $asObject); From 71d0732d5cf96a7e44c814dbd65394e33b8af205 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:58:29 +0000 Subject: [PATCH 04/83] Add tests for HTTP client JSON decode caching Cover empty array caching, scalar value caching, null caching for invalid JSON, and decodeUsing() cache invalidation. --- tests/Http/HttpClientTest.php | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 53b5d468c..720f19ead 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -358,6 +358,61 @@ public function testResponseBodyCasting() $this->assertSame(['foo' => 'bar'], $response['result']); } + public function testJsonCachesEmptyArray() + { + $this->factory->fake([ + '*' => '[]', + ]); + + $response = $this->factory->get('http://foo.com/api'); + + $this->assertSame([], $response->json()); + $this->assertSame([], $response->json()); + } + + public function testJsonCachesScalarValues() + { + $this->factory->fake([ + 'foo.com/zero' => '0', + 'foo.com/false' => 'false', + ]); + + $response = $this->factory->get('http://foo.com/zero'); + $this->assertSame(0, $response->json()); + $this->assertSame(0, $response->json()); + + $response = $this->factory->get('http://foo.com/false'); + $this->assertSame(false, $response->json()); + $this->assertSame(false, $response->json()); + } + + public function testJsonCachesNullForInvalidJson() + { + $this->factory->fake([ + '*' => 'not valid json', + ]); + + $response = $this->factory->get('http://foo.com/api'); + + $this->assertNull($response->json()); + $this->assertNull($response->json()); + } + + public function testDecodeUsingResetsCacheAndReDecodesWithNewCallback() + { + $this->factory->fake([ + '*' => '{"key":"value"}', + ]); + + $response = $this->factory->get('http://foo.com/api'); + + $this->assertSame(['key' => 'value'], $response->json()); + + $response->decodeUsing(fn (string $body, bool $asObject) => ['custom' => 'decoded']); + + $this->assertSame(['custom' => 'decoded'], $response->json()); + } + public function testResponseObjectAsArray() { $this->factory->fake([ From 0d21cfd0dfeaaf15fd9770d99804b8fb0f60c084 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:58:39 +0000 Subject: [PATCH 05/83] Fix AssertableJsonString::jsonSearchStrings() to accept int keys Numeric array keys from assertJsonMissing(['value']) are int, not string. The parameter was incorrectly narrowed to string only. --- src/testing/src/AssertableJsonString.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/testing/src/AssertableJsonString.php b/src/testing/src/AssertableJsonString.php index 9f8c1053f..da98abc9e 100644 --- a/src/testing/src/AssertableJsonString.php +++ b/src/testing/src/AssertableJsonString.php @@ -317,7 +317,7 @@ protected function assertJsonMessage(array $data): string /** * Get the strings we need to search for when examining the JSON. */ - protected function jsonSearchStrings(string $key, mixed $value): array + protected function jsonSearchStrings(string|int $key, mixed $value): array { $needle = Str::substr(json_encode([$key => $value], JSON_UNESCAPED_UNICODE), 1, -1); From 0a623d42eaf9dcb0ef5668d82e42099b9965741e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:58:51 +0000 Subject: [PATCH 06/83] Add baseUrl property to Testbench TestCase Derived from config('app.url') after app bootstrap. Provides the base application URL for tests that need to construct full URLs in assertions, matching Orchestra Testbench's convention. --- src/testbench/src/TestCase.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index f821e04f9..29ce264f6 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -32,6 +32,11 @@ class TestCase extends BaseTestCase implements Contracts\TestCase { use Concerns\Testing; + /** + * The base URL to use while testing the application. + */ + protected string $baseUrl = 'http://localhost'; + /** * Automatically loads environment variables when available. */ @@ -60,6 +65,8 @@ protected function setUp(): void parent::setUp(); + $this->baseUrl = config('app.url', 'http://localhost'); + // Execute BeforeEach attributes INSIDE coroutine context // (matches where setUpTraits runs in Foundation TestCase) $this->runInCoroutine(fn () => $this->setUpTheTestEnvironmentUsingTestCase()); From 0980130ac462903051e429dc8e884c3ad2e0694a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:58:59 +0000 Subject: [PATCH 07/83] Add Inertia package skeleton --- src/inertia/LICENSE.md | 23 ++++++++++++++ src/inertia/README.md | 5 +++ src/inertia/composer.json | 65 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 src/inertia/LICENSE.md create mode 100644 src/inertia/README.md create mode 100644 src/inertia/composer.json diff --git a/src/inertia/LICENSE.md b/src/inertia/LICENSE.md new file mode 100644 index 000000000..925a9ddeb --- /dev/null +++ b/src/inertia/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Jonathan Reinink + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/inertia/README.md b/src/inertia/README.md new file mode 100644 index 000000000..37ea26f0e --- /dev/null +++ b/src/inertia/README.md @@ -0,0 +1,5 @@ +# Inertia.js Adapter for Hypervel + +The Inertia.js server-side adapter for Hypervel, providing middleware, response factories, SSR support, Blade directives, and testing utilities. + +Ported from the official [inertiajs/inertia-laravel](https://github.com/inertiajs/inertia-laravel) adapter with Swoole-specific optimisations: coroutine-safe per-request state isolation, worker-lifetime caching for immutable metadata, SSR timeouts with circuit breaker protection. diff --git a/src/inertia/composer.json b/src/inertia/composer.json new file mode 100644 index 000000000..f91cc7754 --- /dev/null +++ b/src/inertia/composer.json @@ -0,0 +1,65 @@ +{ + "name": "hypervel/inertia", + "type": "library", + "description": "The Inertia.js adapter for Hypervel.", + "license": "MIT", + "keywords": [ + "php", + "inertia", + "swoole", + "hypervel" + ], + "authors": [ + { + "name": "Albert Chen", + "email": "albert@hypervel.org" + }, + { + "name": "Raj Siva-Rajah", + "homepage": "https://github.com/binaryfire" + } + ], + "support": { + "issues": "https://github.com/hypervel/components/issues", + "source": "https://github.com/hypervel/components" + }, + "autoload": { + "psr-4": { + "Hypervel\\Inertia\\": "src/" + }, + "files": [ + "src/helpers.php" + ] + }, + "require": { + "php": "^8.4", + "hypervel/console": "^0.4", + "hypervel/container": "^0.4", + "hypervel/context": "^0.4", + "hypervel/contracts": "^0.4", + "hypervel/foundation": "^0.4", + "hypervel/http": "^0.4", + "hypervel/macroable": "^0.4", + "hypervel/pagination": "^0.4", + "hypervel/routing": "^0.4", + "hypervel/session": "^0.4", + "hypervel/support": "^0.4", + "hypervel/testing": "^0.4", + "hypervel/view": "^0.4", + "symfony/console": "^8.0", + "symfony/process": "^8.0" + }, + "config": { + "sort-packages": true + }, + "extra": { + "hypervel": { + "providers": [ + "Hypervel\\Inertia\\InertiaServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "0.4-dev" + } + } +} From 2284fb40901402bff9072fdc4200de72df120e23 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:59:10 +0000 Subject: [PATCH 08/83] Register inertia package in root composer.json Add PSR-4 autoload, helpers.php to files, replace entry, and service provider to extra.hypervel.providers. --- composer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composer.json b/composer.json index eefd8e8d8..59ada898f 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,7 @@ "Hypervel\\Hashing\\": "src/hashing/src/", "Hypervel\\Horizon\\": "src/horizon/src/", "Hypervel\\Http\\": "src/http/src/", + "Hypervel\\Inertia\\": "src/inertia/src/", "Hypervel\\JsonSchema\\": "src/json-schema/src/", "Hypervel\\JWT\\": "src/jwt/src/", "Hypervel\\Log\\": "src/log/src/", @@ -106,6 +107,7 @@ "src/collections/src/helpers.php", "src/support/src/functions.php", "src/support/src/helpers.php", + "src/inertia/src/helpers.php", "src/testbench/src/functions.php" ] }, @@ -213,6 +215,7 @@ "hypervel/hashing": "self.version", "hypervel/horizon": "self.version", "hypervel/http": "self.version", + "hypervel/inertia": "self.version", "hypervel/jwt": "self.version", "hypervel/log": "self.version", "hypervel/macroable": "self.version", @@ -299,6 +302,7 @@ "Hypervel\\Filesystem\\FilesystemServiceProvider", "Hypervel\\Hashing\\HashingServiceProvider", "Hypervel\\Http\\HttpServiceProvider", + "Hypervel\\Inertia\\InertiaServiceProvider", "Hypervel\\JWT\\JWTServiceProvider", "Hypervel\\Log\\Context\\ContextServiceProvider", "Hypervel\\Log\\LogServiceProvider", From 1dbea59b24155f5dbe8630c63ccd7123d457dfde Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:59:21 +0000 Subject: [PATCH 09/83] Add InertiaState per-request Context state bag All request-scoped Inertia state lives here instead of on singleton service classes. Stored in CoroutineContext under a single key for complete isolation between concurrent requests. --- src/inertia/src/InertiaState.php | 112 +++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/inertia/src/InertiaState.php diff --git a/src/inertia/src/InertiaState.php b/src/inertia/src/InertiaState.php new file mode 100644 index 000000000..5efecbd57 --- /dev/null +++ b/src/inertia/src/InertiaState.php @@ -0,0 +1,112 @@ + + */ + public array $sharedProps = []; + + /** + * The asset version resolver or value. + */ + public Closure|string|null $version = null; + + /** + * Whether browser history encryption is enabled for this request. + */ + public ?bool $encryptHistory = null; + + /** + * The URL resolver callback for this request. + */ + public ?Closure $urlResolver = null; + + /** + * The component name transformer callback. + */ + public ?Closure $componentTransformer = null; + + // SSR per-request state (replaces upstream SsrState) + + /** + * The page data for the current request's SSR dispatch. + * + * @var array + */ + public array $page = []; + + /** + * The cached SSR response for the current request. + */ + public ?SsrResponse $ssrResponse = null; + + /** + * Whether the SSR gateway has been dispatched for this request. + */ + public bool $ssrDispatched = false; + + // SSR per-request flags (moved from HttpGateway) + + /** + * The condition that determines if SSR is disabled for this request. + */ + public Closure|bool|null $ssrDisabled = null; + + /** + * The paths excluded from SSR for this request. + * + * @var array + */ + public array $ssrExcludedPaths = []; + + /** + * Set the page data and dispatch SSR if not already dispatched. + * + * Used by Blade directives and view components to trigger SSR + * rendering. The result is cached so multiple calls (e.g. both + * @inertia and @inertiaHead) only dispatch once. + * + * @param array $page + */ + public static function dispatchSsr(array $page): ?SsrResponse + { + $state = CoroutineContext::getOrSet(self::CONTEXT_KEY, fn () => new self); + $state->page = $page; + + if (! $state->ssrDispatched) { + $state->ssrDispatched = true; + $state->ssrResponse = app(Gateway::class)->dispatch($state->page); + } + + return $state->ssrResponse; + } +} From cfd23ca69b3d293d2f2dd70ad9ab12662b78db71 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:59:31 +0000 Subject: [PATCH 10/83] Add Inertia support constants (Header, SessionKey) --- src/inertia/src/Support/Header.php | 63 ++++++++++++++++++++++++++ src/inertia/src/Support/SessionKey.php | 23 ++++++++++ 2 files changed, 86 insertions(+) create mode 100644 src/inertia/src/Support/Header.php create mode 100644 src/inertia/src/Support/SessionKey.php diff --git a/src/inertia/src/Support/Header.php b/src/inertia/src/Support/Header.php new file mode 100644 index 000000000..6823e6643 --- /dev/null +++ b/src/inertia/src/Support/Header.php @@ -0,0 +1,63 @@ + Date: Mon, 13 Apr 2026 12:59:43 +0000 Subject: [PATCH 11/83] Add Inertia prop type interfaces --- src/inertia/src/Deferrable.php | 18 ++++++++++ src/inertia/src/IgnoreFirstLoad.php | 9 +++++ src/inertia/src/Mergeable.php | 54 +++++++++++++++++++++++++++++ src/inertia/src/Onceable.php | 48 +++++++++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 src/inertia/src/Deferrable.php create mode 100644 src/inertia/src/IgnoreFirstLoad.php create mode 100644 src/inertia/src/Mergeable.php create mode 100644 src/inertia/src/Onceable.php diff --git a/src/inertia/src/Deferrable.php b/src/inertia/src/Deferrable.php new file mode 100644 index 000000000..4f11458f8 --- /dev/null +++ b/src/inertia/src/Deferrable.php @@ -0,0 +1,18 @@ + + */ + public function matchesOn(): array; + + /** + * Determine if the property should be appended at the root level. + */ + public function appendsAtRoot(): bool; + + /** + * Determine if the property should be prepended at the root level. + */ + public function prependsAtRoot(): bool; + + /** + * Get the paths to append when merging. + * + * @return array + */ + public function appendsAtPaths(): array; + + /** + * Get the paths to prepend when merging. + * + * @return array + */ + public function prependsAtPaths(): array; +} diff --git a/src/inertia/src/Onceable.php b/src/inertia/src/Onceable.php new file mode 100644 index 000000000..a040d3828 --- /dev/null +++ b/src/inertia/src/Onceable.php @@ -0,0 +1,48 @@ + Date: Mon, 13 Apr 2026 12:59:54 +0000 Subject: [PATCH 12/83] Add Inertia provider interfaces --- src/inertia/src/ProvidesInertiaProperties.php | 17 +++++++++++ src/inertia/src/ProvidesInertiaProperty.php | 15 ++++++++++ src/inertia/src/ProvidesScrollMetadata.php | 28 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 src/inertia/src/ProvidesInertiaProperties.php create mode 100644 src/inertia/src/ProvidesInertiaProperty.php create mode 100644 src/inertia/src/ProvidesScrollMetadata.php diff --git a/src/inertia/src/ProvidesInertiaProperties.php b/src/inertia/src/ProvidesInertiaProperties.php new file mode 100644 index 000000000..425c7bf37 --- /dev/null +++ b/src/inertia/src/ProvidesInertiaProperties.php @@ -0,0 +1,17 @@ + + */ + public function toInertiaProperties(RenderContext $context): iterable; +} diff --git a/src/inertia/src/ProvidesInertiaProperty.php b/src/inertia/src/ProvidesInertiaProperty.php new file mode 100644 index 000000000..18350ff5a --- /dev/null +++ b/src/inertia/src/ProvidesInertiaProperty.php @@ -0,0 +1,15 @@ + Date: Mon, 13 Apr 2026 13:00:03 +0000 Subject: [PATCH 13/83] Add Inertia DTOs and exceptions --- .../src/ComponentNotFoundException.php | 11 +++++++++ src/inertia/src/PropertyContext.php | 24 +++++++++++++++++++ src/inertia/src/RenderContext.php | 21 ++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 src/inertia/src/ComponentNotFoundException.php create mode 100644 src/inertia/src/PropertyContext.php create mode 100644 src/inertia/src/RenderContext.php diff --git a/src/inertia/src/ComponentNotFoundException.php b/src/inertia/src/ComponentNotFoundException.php new file mode 100644 index 000000000..2c4640c5c --- /dev/null +++ b/src/inertia/src/ComponentNotFoundException.php @@ -0,0 +1,11 @@ + $props + */ + public function __construct( + public readonly string $key, + public readonly array $props, + public readonly Request $request, + ) { + } +} diff --git a/src/inertia/src/RenderContext.php b/src/inertia/src/RenderContext.php new file mode 100644 index 000000000..a50524eed --- /dev/null +++ b/src/inertia/src/RenderContext.php @@ -0,0 +1,21 @@ + Date: Mon, 13 Apr 2026 13:00:12 +0000 Subject: [PATCH 14/83] Add Inertia ResolvesCallables trait --- src/inertia/src/ResolvesCallables.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/inertia/src/ResolvesCallables.php diff --git a/src/inertia/src/ResolvesCallables.php b/src/inertia/src/ResolvesCallables.php new file mode 100644 index 000000000..8fa0e71c6 --- /dev/null +++ b/src/inertia/src/ResolvesCallables.php @@ -0,0 +1,26 @@ +useAsCallable($value) ? App::call($value) : $value; + } + + /** + * Determine if the given value is callable, but not a string. + */ + protected function useAsCallable(mixed $value): bool + { + return is_object($value) && is_callable($value); + } +} From 2652b6f35994a3494bea5b983e9a22a1dda0b324 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:00:23 +0000 Subject: [PATCH 15/83] Add Inertia DefersProps trait --- src/inertia/src/DefersProps.php | 47 +++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/inertia/src/DefersProps.php diff --git a/src/inertia/src/DefersProps.php b/src/inertia/src/DefersProps.php new file mode 100644 index 000000000..79fccefae --- /dev/null +++ b/src/inertia/src/DefersProps.php @@ -0,0 +1,47 @@ +deferred = true; + $this->deferGroup = $group; + + return $this; + } + + /** + * Determine if this property should be deferred. + */ + public function shouldDefer(): bool + { + return $this->deferred; + } + + /** + * Get the defer group for this property. + */ + public function group(): string + { + return $this->deferGroup ?? 'default'; + } +} From 005c2b6fb42461d7fb6fa1bfb33de3501f1152aa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:00:23 +0000 Subject: [PATCH 16/83] Add Inertia MergesProps trait --- src/inertia/src/MergesProps.php | 192 ++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 src/inertia/src/MergesProps.php diff --git a/src/inertia/src/MergesProps.php b/src/inertia/src/MergesProps.php new file mode 100644 index 000000000..e4610b6ec --- /dev/null +++ b/src/inertia/src/MergesProps.php @@ -0,0 +1,192 @@ + + */ + protected array $matchOn = []; + + /** + * Indicates if the property values should be appended or prepended. + */ + protected bool $append = true; + + /** + * The paths to append. + * + * @var array + */ + protected array $appendsAtPaths = []; + + /** + * The paths to prepend. + * + * @var array + */ + protected array $prependsAtPaths = []; + + /** + * Mark the property for merging. + */ + public function merge(): static + { + $this->merge = true; + + return $this; + } + + /** + * Mark the property for deep merging. + */ + public function deepMerge(): static + { + $this->deepMerge = true; + + return $this->merge(); + } + + /** + * Set the properties to match on for merging. + * + * @param array|string $matchOn + */ + public function matchOn(string|array $matchOn): static + { + $this->matchOn = Arr::wrap($matchOn); + + return $this; + } + + /** + * Determine if the property should be merged. + */ + public function shouldMerge(): bool + { + return $this->merge; + } + + /** + * Determine if the property should be deep merged. + */ + public function shouldDeepMerge(): bool + { + return $this->deepMerge; + } + + /** + * Get the properties to match on for merging. + * + * @return array + */ + public function matchesOn(): array + { + return $this->matchOn; + } + + /** + * Determine if the property should be appended at the root level. + */ + public function appendsAtRoot(): bool + { + return $this->append && $this->mergesAtRoot(); + } + + /** + * Determine if the property should be prepended at the root level. + */ + public function prependsAtRoot(): bool + { + return ! $this->append && $this->mergesAtRoot(); + } + + /** + * Determine if the property merges at the root level. + */ + protected function mergesAtRoot(): bool + { + return count($this->appendsAtPaths) === 0 && count($this->prependsAtPaths) === 0; + } + + /** + * Specify that the value should be appended, optionally providing a key to append and a property to match on. + * + * @param array|bool|string $path + */ + public function append(bool|string|array $path = true, ?string $matchOn = null): static + { + match (true) { + is_bool($path) => $this->append = $path, + is_string($path) => $this->appendsAtPaths[] = $path, + is_array($path) => collect($path)->each( + fn ($value, $key) => is_numeric($key) ? $this->append($value) : $this->append($key, $value) + ), + }; + + if (is_string($path) && $matchOn) { + $this->matchOn([...$this->matchOn, "{$path}.{$matchOn}"]); + } + + return $this; + } + + /** + * Specify that the value should be prepended, optionally providing a key to prepend and a property to match on. + * + * @param array|bool|string $path + */ + public function prepend(bool|string|array $path = true, ?string $matchOn = null): static + { + match (true) { + is_bool($path) => $this->append = ! $path, + is_string($path) => $this->prependsAtPaths[] = $path, + is_array($path) => collect($path)->each( + fn ($value, $key) => is_numeric($key) ? $this->prepend($value) : $this->prepend($key, $value) + ), + }; + + if (is_string($path) && $matchOn) { + $this->matchOn([...$this->matchOn, "{$path}.{$matchOn}"]); + } + + return $this; + } + + /** + * Get the paths to append. + * + * @return array + */ + public function appendsAtPaths(): array + { + return $this->appendsAtPaths; + } + + /** + * Get the paths to prepend. + * + * @return array + */ + public function prependsAtPaths(): array + { + return $this->prependsAtPaths; + } +} From 565768cd94941aea51662b8495bc803a7dcad94e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:00:23 +0000 Subject: [PATCH 17/83] Add Inertia ResolvesOnce trait --- src/inertia/src/ResolvesOnce.php | 124 +++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/inertia/src/ResolvesOnce.php diff --git a/src/inertia/src/ResolvesOnce.php b/src/inertia/src/ResolvesOnce.php new file mode 100644 index 000000000..46ba37da0 --- /dev/null +++ b/src/inertia/src/ResolvesOnce.php @@ -0,0 +1,124 @@ +once = $value; + + if ($as !== null) { + $this->as($as); + } + + if ($until !== null) { + $this->until($until); + } + + return $this; + } + + /** + * Determine if the prop should be resolved only once. + */ + public function shouldResolveOnce(): bool + { + return $this->once; + } + + /** + * Determine if the prop should be forcefully refreshed. + */ + public function shouldBeRefreshed(): bool + { + return $this->refresh; + } + + /** + * Get the custom key for resolving the once prop. + */ + public function getKey(): ?string + { + return $this->key; + } + + /** + * Set a custom key for resolving the once prop. + */ + public function as(BackedEnum|UnitEnum|string $key): static + { + $this->key = match (true) { + $key instanceof BackedEnum => $key->value, + $key instanceof UnitEnum => $key->name, + default => $key, + }; + + return $this; + } + + /** + * Mark the property to be forcefully sent to the client. + */ + public function fresh(bool $value = true): static + { + $this->refresh = $value; + + return $this; + } + + /** + * Set the expiration for the once prop. + */ + public function until(DateTimeInterface|DateInterval|int $delay): static + { + $this->ttl = $this->secondsUntil($delay); + + return $this; + } + + /** + * Get the expiration timestamp in milliseconds for the once prop. + */ + public function expiresAt(): ?int + { + if ($this->ttl === null) { + return null; + } + + return $this->availableAt($this->ttl) * 1000; + } +} From f6009d4cf890d83a7794486d11f174c4d0d1454e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:00:35 +0000 Subject: [PATCH 18/83] Add Inertia SSR interfaces --- src/inertia/src/Ssr/DisablesSsr.php | 15 +++++++++++++++ src/inertia/src/Ssr/ExcludesSsrPaths.php | 15 +++++++++++++++ src/inertia/src/Ssr/Gateway.php | 15 +++++++++++++++ src/inertia/src/Ssr/HasHealthCheck.php | 13 +++++++++++++ 4 files changed, 58 insertions(+) create mode 100644 src/inertia/src/Ssr/DisablesSsr.php create mode 100644 src/inertia/src/Ssr/ExcludesSsrPaths.php create mode 100644 src/inertia/src/Ssr/Gateway.php create mode 100644 src/inertia/src/Ssr/HasHealthCheck.php diff --git a/src/inertia/src/Ssr/DisablesSsr.php b/src/inertia/src/Ssr/DisablesSsr.php new file mode 100644 index 000000000..cb40d1ec6 --- /dev/null +++ b/src/inertia/src/Ssr/DisablesSsr.php @@ -0,0 +1,15 @@ +|string $paths + */ + public function except(array|string $paths): void; +} diff --git a/src/inertia/src/Ssr/Gateway.php b/src/inertia/src/Ssr/Gateway.php new file mode 100644 index 000000000..2504eb05a --- /dev/null +++ b/src/inertia/src/Ssr/Gateway.php @@ -0,0 +1,15 @@ + $page + */ + public function dispatch(array $page): ?Response; +} diff --git a/src/inertia/src/Ssr/HasHealthCheck.php b/src/inertia/src/Ssr/HasHealthCheck.php new file mode 100644 index 000000000..3e84a1e2a --- /dev/null +++ b/src/inertia/src/Ssr/HasHealthCheck.php @@ -0,0 +1,13 @@ + Date: Mon, 13 Apr 2026 13:00:50 +0000 Subject: [PATCH 19/83] Add Inertia SSR value types (Response, SsrErrorType) --- src/inertia/src/Ssr/Response.php | 20 ++++++++++++++++++++ src/inertia/src/Ssr/SsrErrorType.php | 26 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 src/inertia/src/Ssr/Response.php create mode 100644 src/inertia/src/Ssr/SsrErrorType.php diff --git a/src/inertia/src/Ssr/Response.php b/src/inertia/src/Ssr/Response.php new file mode 100644 index 000000000..1e9c47b6e --- /dev/null +++ b/src/inertia/src/Ssr/Response.php @@ -0,0 +1,20 @@ + Date: Mon, 13 Apr 2026 13:00:50 +0000 Subject: [PATCH 20/83] Add Inertia SSR failure event --- src/inertia/src/Ssr/SsrRenderFailed.php | 68 +++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/inertia/src/Ssr/SsrRenderFailed.php diff --git a/src/inertia/src/Ssr/SsrRenderFailed.php b/src/inertia/src/Ssr/SsrRenderFailed.php new file mode 100644 index 000000000..affcd2faa --- /dev/null +++ b/src/inertia/src/Ssr/SsrRenderFailed.php @@ -0,0 +1,68 @@ + $page The page data that was being rendered + * @param string $error The error message + * @param SsrErrorType $type The error type + * @param null|string $hint A helpful hint on how to fix the error + * @param null|string $browserApi The browser API that was accessed (if type is browser-api) + * @param null|string $stack The stack trace + * @param null|string $sourceLocation The source location (file:line:column) where the error occurred + */ + public function __construct( + public readonly array $page, + public readonly string $error, + public readonly SsrErrorType $type = SsrErrorType::Unknown, + public readonly ?string $hint = null, + public readonly ?string $browserApi = null, + public readonly ?string $stack = null, + public readonly ?string $sourceLocation = null, + ) { + } + + /** + * Get the component name from the page data. + */ + public function component(): string + { + return $this->page['component'] ?? 'Unknown'; + } + + /** + * Get the URL from the page data. + */ + public function url(): string + { + return $this->page['url'] ?? '/'; + } + + /** + * Convert the event to an array for logging. + * + * @return array + */ + public function toArray(): array + { + return array_filter([ + 'component' => $this->component(), + 'url' => $this->url(), + 'error' => $this->error, + 'type' => $this->type->value, + 'hint' => $this->hint, + 'browser_api' => $this->browserApi, + 'source_location' => $this->sourceLocation, + ]); + } +} From 120779e2134827c5e601e2ae95b884232a122b8d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:00:50 +0000 Subject: [PATCH 21/83] Add Inertia SSR exception --- src/inertia/src/Ssr/SsrException.php | 68 ++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/inertia/src/Ssr/SsrException.php diff --git a/src/inertia/src/Ssr/SsrException.php b/src/inertia/src/Ssr/SsrException.php new file mode 100644 index 000000000..e89242d08 --- /dev/null +++ b/src/inertia/src/Ssr/SsrException.php @@ -0,0 +1,68 @@ +component(), + $event->error + ); + + if ($event->sourceLocation) { + $message .= sprintf(' at %s', $event->sourceLocation); + } + + $exception = new self($message); + $exception->event = $event; + + return $exception; + } + + /** + * The SSR render failed event containing error details. + */ + public ?SsrRenderFailed $event = null; + + /** + * Get the component that failed to render. + */ + public function component(): ?string + { + return $this->event?->component(); + } + + /** + * Get the error type. + */ + public function type(): ?SsrErrorType + { + return $this->event?->type; + } + + /** + * Get the hint for fixing the error. + */ + public function hint(): ?string + { + return $this->event?->hint; + } + + /** + * Get the source location where the error occurred. + */ + public function sourceLocation(): ?string + { + return $this->event?->sourceLocation; + } +} From f5b8dadb718e3e22dff502925a5f9c58d0b63d70 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:00:50 +0000 Subject: [PATCH 22/83] Add Inertia SSR bundle detector with worker-lifetime caching --- src/inertia/src/Ssr/BundleDetector.php | 61 ++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/inertia/src/Ssr/BundleDetector.php diff --git a/src/inertia/src/Ssr/BundleDetector.php b/src/inertia/src/Ssr/BundleDetector.php new file mode 100644 index 000000000..0165783cb --- /dev/null +++ b/src/inertia/src/Ssr/BundleDetector.php @@ -0,0 +1,61 @@ +findBundle(); + self::$bundleDetected = true; + } + + return self::$cachedBundle; + } + + /** + * Search candidate paths for the SSR bundle file. + */ + private function findBundle(): ?string + { + return collect([ + config('inertia.ssr.bundle'), + base_path('bootstrap/ssr/ssr.js'), + base_path('bootstrap/ssr/app.js'), + base_path('bootstrap/ssr/ssr.mjs'), + base_path('bootstrap/ssr/app.mjs'), + public_path('js/ssr.js'), + public_path('js/app.js'), + ])->filter()->first(function ($path) { + return file_exists($path); + }); + } + + /** + * Reset the cached bundle detection state. + */ + public static function flushState(): void + { + self::$cachedBundle = null; + self::$bundleDetected = false; + } +} From e893b4428ac238bd86fc07ea74f3feaba991f380 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:00:50 +0000 Subject: [PATCH 23/83] Add Inertia SSR HTTP gateway Stateless gateway with per-request state in InertiaState Context. Includes SSR timeouts, worker-local circuit breaker, and direct event dispatch (avoids double construction). --- src/inertia/src/Ssr/HttpGateway.php | 253 ++++++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 src/inertia/src/Ssr/HttpGateway.php diff --git a/src/inertia/src/Ssr/HttpGateway.php b/src/inertia/src/Ssr/HttpGateway.php new file mode 100644 index 000000000..6a3809659 --- /dev/null +++ b/src/inertia/src/Ssr/HttpGateway.php @@ -0,0 +1,253 @@ + new InertiaState); + } + + /** + * Dispatch the Inertia page to the SSR engine via HTTP. + * + * @param array $page + */ + public function dispatch(array $page, ?Request $request = null): ?Response + { + if (! $this->ssrIsEnabled($request ?? request())) { + return null; + } + + $isHot = Vite::isRunningHot(); + + if (! $isHot && $this->shouldEnsureBundleExists() && ! $this->bundleExists()) { + return null; + } + + $url = $isHot + ? $this->getHotUrl('/__inertia_ssr') + : $this->getProductionUrl('/render'); + + $connectTimeout = (int) config('inertia.ssr.connect_timeout', 2); + $timeout = (int) config('inertia.ssr.timeout', 5); + + try { + $response = Http::connectTimeout($connectTimeout) + ->timeout($timeout) + ->post($url, $page); + + if ($response->failed()) { + $this->handleSsrFailure($page, $response->json()); + + return null; + } + + if (! $data = $response->json()) { + return null; + } + + // SSR succeeded — clear any previous backoff + self::$ssrUnavailableUntil = null; + + return new Response( + implode("\n", $data['head'] ?? []), + $data['body'] ?? '' + ); + } catch (Exception $e) { + if ($e instanceof StrayRequestException || $e instanceof SsrException) { + throw $e; + } + + $this->handleSsrFailure($page, [ + 'error' => $e->getMessage(), + 'type' => 'connection', + ]); + + return null; + } + } + + /** + * Set the condition that determines if SSR should be disabled. + */ + public function disable(Closure|bool $condition): void + { + $this->state()->ssrDisabled = $condition; + } + + /** + * Exclude the given paths from server-side rendering. + * + * @param array|string $paths + */ + public function except(array|string $paths): void + { + $state = $this->state(); + $state->ssrExcludedPaths = array_merge($state->ssrExcludedPaths, Arr::wrap($paths)); + } + + /** + * Get the paths excluded from SSR for the current request. + * + * Overrides ExcludesPaths::getExcludedPaths() to read from + * per-request InertiaState instead of an instance property. + * + * @return array + */ + public function getExcludedPaths(): array + { + return $this->state()->ssrExcludedPaths; + } + + /** + * Handle an SSR rendering failure. + * + * Sets the circuit breaker backoff and dispatches a failure event. + * + * @param array $page + * + * @throws SsrException + */ + protected function handleSsrFailure(array $page, mixed $error): void + { + // Normalize: json() returns mixed — scalar/null responses become empty array + // so the ?? defaults below produce a clean SsrRenderFailed event. + $error = is_array($error) ? $error : []; + + // Activate circuit breaker to avoid pile-up on a dead SSR server + self::$ssrUnavailableUntil = microtime(true) + (float) config('inertia.ssr.backoff', 5.0); + + $event = new SsrRenderFailed( + page: $page, + error: $error['error'] ?? 'Unknown SSR error', + type: SsrErrorType::fromString($error['type'] ?? null), + hint: $error['hint'] ?? null, + browserApi: $error['browserApi'] ?? null, + stack: $error['stack'] ?? null, + sourceLocation: $error['sourceLocation'] ?? null, + ); + + // Dispatch the already-built event directly (avoids double construction) + event($event); + + // Throw an exception if configured (useful for E2E testing) + if (config('inertia.ssr.throw_on_error', false)) { + throw SsrException::fromEvent($event); + } + } + + /** + * Determine if the SSR feature is enabled. + */ + protected function ssrIsEnabled(Request $request): bool + { + // Circuit breaker: skip SSR if recently failed + if (self::$ssrUnavailableUntil !== null && microtime(true) < self::$ssrUnavailableUntil) { + return false; + } + + $state = $this->state(); + + $enabled = $state->ssrDisabled !== null + ? ! $this->resolveCallable($state->ssrDisabled) + : config('inertia.ssr.enabled', true); + + return $enabled && ! $this->inExceptArray($request); + } + + /** + * Determine if the SSR server is healthy. + */ + public function isHealthy(): bool + { + $connectTimeout = (int) config('inertia.ssr.connect_timeout', 2); + $timeout = (int) config('inertia.ssr.timeout', 5); + + try { + return Http::connectTimeout($connectTimeout) + ->timeout($timeout) + ->get($this->getProductionUrl('/health')) + ->successful(); + } catch (Exception $e) { + if ($e instanceof StrayRequestException) { + throw $e; + } + + return false; + } + } + + /** + * Determine if the bundle existence should be ensured. + */ + protected function shouldEnsureBundleExists(): bool + { + return (bool) config('inertia.ssr.ensure_bundle_exists', true); + } + + /** + * Check if an SSR bundle exists. + */ + protected function bundleExists(): bool + { + return app(BundleDetector::class)->detect() !== null; + } + + /** + * Get the production SSR server URL. + */ + public function getProductionUrl(string $path = '/'): string + { + $path = Str::start($path, '/'); + $baseUrl = rtrim((string) config('inertia.ssr.url', 'http://127.0.0.1:13714'), '/'); + + return $baseUrl . $path; + } + + /** + * Get the Vite hot SSR URL. + */ + protected function getHotUrl(string $path = '/'): string + { + return rtrim(file_get_contents(Vite::hotFile())) . $path; + } + + /** + * Reset the circuit breaker state. + */ + public static function flushState(): void + { + self::$ssrUnavailableUntil = null; + } +} From 252fd85fceca81e7fc533b17e4379b38551dcc38 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:34 +0000 Subject: [PATCH 24/83] Port AlwaysProp from upstream Inertia adapter --- src/inertia/src/AlwaysProp.php | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/inertia/src/AlwaysProp.php diff --git a/src/inertia/src/AlwaysProp.php b/src/inertia/src/AlwaysProp.php new file mode 100644 index 000000000..68b838f16 --- /dev/null +++ b/src/inertia/src/AlwaysProp.php @@ -0,0 +1,35 @@ +value = $value; + } + + /** + * Resolve the property value. + */ + public function __invoke(): mixed + { + return $this->resolveCallable($this->value); + } +} From dffcc46277145207844dc759a22aea814e7bece6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:35 +0000 Subject: [PATCH 25/83] Port DeferProp from upstream Inertia adapter --- src/inertia/src/DeferProp.php | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/inertia/src/DeferProp.php diff --git a/src/inertia/src/DeferProp.php b/src/inertia/src/DeferProp.php new file mode 100644 index 000000000..9dd53b447 --- /dev/null +++ b/src/inertia/src/DeferProp.php @@ -0,0 +1,41 @@ +callback = $callback; + $this->defer($group); + } + + /** + * Resolve the property value. + */ + public function __invoke(): mixed + { + return $this->resolveCallable($this->callback); + } +} From 75a4579baa4ef89d4e5f286b5da53d20987e26ab Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:35 +0000 Subject: [PATCH 26/83] Port MergeProp from upstream Inertia adapter --- src/inertia/src/MergeProp.php | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/inertia/src/MergeProp.php diff --git a/src/inertia/src/MergeProp.php b/src/inertia/src/MergeProp.php new file mode 100644 index 000000000..d45c63cab --- /dev/null +++ b/src/inertia/src/MergeProp.php @@ -0,0 +1,38 @@ +value = $value; + $this->merge = true; + } + + /** + * Resolve the property value. + */ + public function __invoke(): mixed + { + return $this->resolveCallable($this->value); + } +} From 2352a9dabf18b92909f3354d38f7e5350c6b53d4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:35 +0000 Subject: [PATCH 27/83] Port OnceProp from upstream Inertia adapter --- src/inertia/src/OnceProp.php | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/inertia/src/OnceProp.php diff --git a/src/inertia/src/OnceProp.php b/src/inertia/src/OnceProp.php new file mode 100644 index 000000000..82a0b259a --- /dev/null +++ b/src/inertia/src/OnceProp.php @@ -0,0 +1,35 @@ +callback = $callback; + $this->once = true; + } + + /** + * Resolve the property value. + */ + public function __invoke(): mixed + { + return $this->resolveCallable($this->callback); + } +} From e3f90b02b33d32e4984960290fda9f16c067e4e4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:35 +0000 Subject: [PATCH 28/83] Port OptionalProp from upstream Inertia adapter --- src/inertia/src/OptionalProp.php | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/inertia/src/OptionalProp.php diff --git a/src/inertia/src/OptionalProp.php b/src/inertia/src/OptionalProp.php new file mode 100644 index 000000000..625127a1e --- /dev/null +++ b/src/inertia/src/OptionalProp.php @@ -0,0 +1,38 @@ +callback = $callback; + } + + /** + * Resolve the property value. + */ + public function __invoke(): mixed + { + return $this->resolveCallable($this->callback); + } +} From 5762a63c190d7260f1f003ade3869c954ce7f731 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:42 +0000 Subject: [PATCH 29/83] Implement ScrollMetadata for paginator integration --- src/inertia/src/ScrollMetadata.php | 102 +++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/inertia/src/ScrollMetadata.php diff --git a/src/inertia/src/ScrollMetadata.php b/src/inertia/src/ScrollMetadata.php new file mode 100644 index 000000000..b455a4072 --- /dev/null +++ b/src/inertia/src/ScrollMetadata.php @@ -0,0 +1,102 @@ + + */ +class ScrollMetadata implements Arrayable, ProvidesScrollMetadata +{ + /** + * Create a new scroll metadata instance. + */ + public function __construct( + protected readonly string $pageName, + protected readonly int|string|null $previousPage = null, + protected readonly int|string|null $nextPage = null, + protected readonly int|string|null $currentPage = null, + ) { + } + + /** + * Create a scroll metadata instance from a paginator. + */ + public static function fromPaginator(mixed $value): self + { + $paginator = $value instanceof JsonResource ? $value->resource : $value; + + if ($paginator instanceof CursorPaginator) { + return new self( + $cursorName = $paginator->getCursorName(), + $paginator->previousCursor()?->encode(), + $paginator->nextCursor()?->encode(), + $paginator->onFirstPage() ? 1 : (CursorPaginator::resolveCurrentCursor($cursorName)?->encode() ?? 1) + ); + } + + if ($paginator instanceof LengthAwarePaginator || $paginator instanceof Paginator) { + return new self( + $paginator->getPageName(), + $paginator->currentPage() > 1 ? $paginator->currentPage() - 1 : null, + $paginator->hasMorePages() ? $paginator->currentPage() + 1 : null, + $paginator->currentPage(), + ); + } + + throw new InvalidArgumentException('The given value is not a Hypervel paginator instance. Use a custom callback to extract pagination metadata.'); + } + + /** + * Get the page name parameter. + */ + public function getPageName(): string + { + return $this->pageName; + } + + /** + * Get the previous page identifier. + */ + public function getPreviousPage(): int|string|null + { + return $this->previousPage; + } + + /** + * Get the next page identifier. + */ + public function getNextPage(): int|string|null + { + return $this->nextPage; + } + + /** + * Get the current page identifier. + */ + public function getCurrentPage(): int|string|null + { + return $this->currentPage; + } + + /** + * Convert the scroll metadata instance to an array. + */ + public function toArray(): array + { + return [ + 'pageName' => $this->getPageName(), + 'previousPage' => $this->getPreviousPage(), + 'nextPage' => $this->getNextPage(), + 'currentPage' => $this->getCurrentPage(), + ]; + } +} From 9f3f28240474d045aedf44ce2914caf7588424b4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:42 +0000 Subject: [PATCH 30/83] Implement ScrollProp for infinite scroll support --- src/inertia/src/ScrollProp.php | 131 +++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/inertia/src/ScrollProp.php diff --git a/src/inertia/src/ScrollProp.php b/src/inertia/src/ScrollProp.php new file mode 100644 index 000000000..732928e75 --- /dev/null +++ b/src/inertia/src/ScrollProp.php @@ -0,0 +1,131 @@ +merge = true; + $this->value = $value; + $this->wrapper = $wrapper; + $this->metadata = $metadata; + } + + /** + * Configure the merge strategy based on the infinite scroll merge intent header. + * + * The frontend InfiniteScroll component sends its merge intent directly, + * eliminating the need for direction-based logic on the backend. + */ + public function configureMergeIntent(?Request $request = null): static + { + $request ??= request(); + + return $request->header(Header::INFINITE_SCROLL_MERGE_INTENT) === 'prepend' + ? $this->prepend($this->wrapper) + : $this->append($this->wrapper); + } + + /** + * Resolve the scroll metadata provider. + */ + protected function resolveMetadataProvider(): ProvidesScrollMetadata + { + if ($this->metadata instanceof ProvidesScrollMetadata) { + return $this->metadata; + } + + $value = $this(); + + if (is_null($this->metadata)) { + return ScrollMetadata::fromPaginator($value); + } + + return call_user_func($this->metadata, $value); + } + + /** + * Get the pagination meta information. + * + * @return array{pageName: string, previousPage: null|int|string, nextPage: null|int|string, currentPage: null|int|string} + */ + public function metadata(): array + { + $metadataProvider = $this->resolveMetadataProvider(); + + return [ + 'pageName' => $metadataProvider->getPageName(), + 'previousPage' => $metadataProvider->getPreviousPage(), + 'nextPage' => $metadataProvider->getNextPage(), + 'currentPage' => $metadataProvider->getCurrentPage(), + ]; + } + + /** + * Resolve the property value. + * + * @return T + */ + public function __invoke(): mixed + { + if (isset($this->resolved)) { + return $this->resolved; + } + + return $this->resolved = $this->resolveCallable($this->value); + } +} From 63e4c2fb80cc29e2d1443a636c40df7bf97704ab Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:54 +0000 Subject: [PATCH 31/83] Props resolution engine for Inertia responses --- src/inertia/src/PropsResolver.php | 675 ++++++++++++++++++++++++++++++ 1 file changed, 675 insertions(+) create mode 100644 src/inertia/src/PropsResolver.php diff --git a/src/inertia/src/PropsResolver.php b/src/inertia/src/PropsResolver.php new file mode 100644 index 000000000..15fced8f9 --- /dev/null +++ b/src/inertia/src/PropsResolver.php @@ -0,0 +1,675 @@ + + */ + protected ?array $only; + + /** + * The props to exclude from the partial response. + * + * @var null|array + */ + protected ?array $except; + + /** + * The props that should have their merge state reset. + * + * @var array + */ + protected array $resetProps; + + /** + * The once-props that the client has already loaded. + * + * @var array + */ + protected array $loadedOnceProps; + + /** + * The deferred props grouped by their defer group. + * + * @var array> + */ + protected array $deferredProps = []; + + /** + * The props that should be appended to existing client-side data. + * + * @var array + */ + protected array $mergeProps = []; + + /** + * The props that should be prepended to existing client-side data. + * + * @var array + */ + protected array $prependProps = []; + + /** + * The props that should be deep merged with existing client-side data. + * + * @var array + */ + protected array $deepMergeProps = []; + + /** + * The key matching strategies for mergeable props. + * + * @var array + */ + protected array $matchPropsOn = []; + + /** + * The scroll pagination metadata for each scroll prop. + * + * @var array> + */ + protected array $scrollProps = []; + + /** + * The once-prop metadata for each once prop. + * + * @var array> + */ + protected array $onceProps = []; + + /** + * The top-level keys of shared props. + * + * @var array + */ + protected array $sharedPropKeys = []; + + /** + * Create a new props resolver instance. + */ + public function __construct(Request $request, string $component) + { + $this->request = $request; + $this->component = $component; + + $this->isPartial = $request->header(Header::PARTIAL_COMPONENT) === $component; + $this->isInertia = (bool) $request->header(Header::INERTIA); + $this->only = $this->parseHeader(Header::PARTIAL_ONLY); + $this->except = $this->parseHeader(Header::PARTIAL_EXCEPT); + $this->resetProps = $this->parseHeader(Header::RESET) ?? []; + $this->loadedOnceProps = $this->parseHeader(Header::EXCEPT_ONCE_PROPS) ?? []; + } + + /** + * Resolve the given shared and page props, collecting their metadata. + * + * @param array $shared + * @param array $props + * @return array{array, array} + */ + public function resolve(array $shared, array $props): array + { + $props = array_merge($this->resolveSharedProps($shared), $props); + + return [ + $this->resolveProps($this->unpackDotProps($props)), + $this->buildMetadata(), + ]; + } + + /** + * Resolve shared property providers and collect shared prop keys. + * + * @param array $shared + * @return array + */ + protected function resolveSharedProps(array $shared): array + { + $resolved = $this->resolvePropertyProviders($shared); + + if (! config('inertia.expose_shared_prop_keys', true)) { + return $resolved; + } + + foreach (array_keys($resolved) as $key) { + $this->sharedPropKeys[] = str_contains((string) $key, '.') + ? strstr((string) $key, '.', true) + : (string) $key; + } + + $this->sharedPropKeys = array_values(array_unique($this->sharedPropKeys)); + + return $resolved; + } + + /** + * Resolve ProvidesInertiaProperties instances into keyed props. + * + * @param array $props + * @return array + */ + protected function resolvePropertyProviders(array $props): array + { + $context = null; + $result = []; + + foreach ($props as $key => $value) { + if (is_numeric($key) && $value instanceof ProvidesInertiaProperties) { + $context ??= new RenderContext($this->component, $this->request); + + /** @var array $provided */ + $provided = collect($value->toInertiaProperties($context))->all(); + $result = array_merge($result, $provided); + } else { + $result[$key] = $value; + } + } + + return $result; + } + + /** + * Build the non-empty metadata arrays for the page response. + * + * @return array + */ + protected function buildMetadata(): array + { + return array_filter([ + 'sharedProps' => $this->sharedPropKeys, + 'mergeProps' => $this->mergeProps, + 'prependProps' => $this->prependProps, + 'deepMergeProps' => $this->deepMergeProps, + 'matchPropsOn' => $this->matchPropsOn, + 'deferredProps' => $this->deferredProps, + 'scrollProps' => $this->scrollProps, + 'onceProps' => $this->onceProps, + ], fn ($value) => count($value) > 0); + } + + /** + * Recursively resolve the props tree, collecting metadata along the way. + * + * @param array $props + * @return array + */ + protected function resolveProps(array $props, string $prefix = '', bool $parentWasResolved = false): array + { + $props = $this->resolvePropertyProviders($props); + $result = []; + + foreach ($props as $key => $value) { + $path = $prefix === '' ? $key : "{$prefix}.{$key}"; + $prop = $value; + + // On partial requests, we only include props that match the paths + // specified in the request headers. AlwaysProp instances and the + // children of already-resolved values bypass this filter. + if (! $this->shouldIncludeInPartialResponse($prop, $path, $parentWasResolved)) { + continue; + } + + // On initial page loads, certain prop types (e.g. DeferProp, + // OptionalProp) are excluded before resolution to avoid + // executing their closures unnecessarily. + if (! $this->isPartial && $this->excludeFromInitialResponse($prop, $path)) { + continue; + } + + $value = $this->resolveValue($prop, $path, $props); + + // A closure may return a prop type instead of a plain value. When + // this happens, we unwrap it one more level so the prop type can + // participate in filtering and metadata collection below. + if ($value !== $prop && $this->isPropType($value)) { + $prop = $value; + + // Check again after unwrapping: the resolved prop type may + // itself need to be excluded from the initial response. + if (! $this->isPartial && $this->excludeFromInitialResponse($prop, $path)) { + continue; + } + + $value = $this->resolveValue($prop, $path, $props); + } + + $this->collectMetadata($prop, $path); + + // When the resolved value is an array, we recurse into it. If the + // original prop was not already an array (e.g. a closure that + // returned one), its children bypass partial filtering. + $result[$key] = is_array($value) + ? $this->resolveProps($value, $path, $parentWasResolved || ! is_array($prop)) + : $value; + } + + return $result; + } + + /** + * Determine if a prop should be included in a partial response. AlwaysProp + * and children of already-resolved values bypass partial filtering. + */ + protected function shouldIncludeInPartialResponse(mixed $prop, string $path, bool $parentWasResolved): bool + { + if (! $this->isPartial || $prop instanceof AlwaysProp || $parentWasResolved) { + return true; + } + + return $this->pathMatchesPartialRequest($path); + } + + /** + * Determine if the given path matches the current partial request using + * bidirectional prefix matching on the only/except headers. + */ + protected function pathMatchesPartialRequest(string $path): bool + { + if ($this->only !== null && ! $this->matchesOnly($path) && ! $this->leadsToOnly($path)) { + return false; + } + + if ($this->except !== null && $this->matchesExcept($path)) { + return false; + } + + return true; + } + + /** + * Determine if a prop should be excluded from the initial page response. + * Each exclusion type collects its metadata before the prop is removed. + */ + protected function excludeFromInitialResponse(mixed $prop, string $path): bool + { + // OptionalProp and DeferProp implement IgnoreFirstLoad and are never + // sent on the initial page load. They still contribute deferred, + // merge, and once metadata for the client to act on. + if ($prop instanceof IgnoreFirstLoad) { + return $this->excludeIgnoredProp($prop, $path); + } + + // ScrollProp and other Deferrable types may be configured to defer + // their initial load. They contribute deferred-group and merge + // metadata so the client knows to request them separately. + if ($prop instanceof Deferrable && $prop->shouldDefer()) { + return $this->excludeDeferredProp($prop, $path); + } + + // Once-props that the client has already loaded are excluded on + // subsequent Inertia visits to avoid sending duplicate data. + // The client tracks loaded once-props via the except-once header. + if ($this->isInertia && $this->wasAlreadyLoadedByClient($prop, $path)) { + return $this->excludeAlreadyLoadedProp($prop, $path); + } + + return false; + } + + /** + * Exclude an IgnoreFirstLoad prop from the initial response while + * collecting its deferred, merge, and once metadata. + */ + protected function excludeIgnoredProp(mixed $prop, string $path): bool + { + if ($prop instanceof Deferrable && $prop->shouldDefer() + && ! $this->wasAlreadyLoadedByClient($prop, $path)) { + $this->collectDeferredPropMetadata($path, $prop); + } + + if ($prop instanceof Mergeable && $prop->shouldMerge()) { + $this->collectMergeableMetadata($path, $prop); + } + + if ($prop instanceof Onceable && $prop->shouldResolveOnce()) { + $this->collectOnceMetadata($path, $prop); + } + + return true; + } + + /** + * Exclude a deferred prop from the initial response while + * collecting its deferred-group and merge metadata. + */ + protected function excludeDeferredProp(Deferrable $prop, string $path): bool + { + $this->collectDeferredPropMetadata($path, $prop); + + if ($prop instanceof Mergeable && $prop->shouldMerge()) { + $this->collectMergeableMetadata($path, $prop); + } + + return true; + } + + /** + * Exclude a once-prop that the client has already loaded while + * preserving its once metadata for the client. + */ + protected function excludeAlreadyLoadedProp(mixed $prop, string $path): bool + { + $this->collectOnceMetadata($path, $prop); + + return true; + } + + /** + * Determine if a once-prop has already been loaded by the client. + */ + protected function wasAlreadyLoadedByClient(mixed $prop, string $path): bool + { + return $prop instanceof Onceable + && $prop->shouldResolveOnce() + && ! $prop->shouldBeRefreshed() + && in_array($prop->getKey() ?? $path, $this->loadedOnceProps); + } + + /** + * Resolve a single prop value through the resolution pipeline. + * + * @param array $siblings + */ + protected function resolveValue(mixed $value, string $path, array $siblings): mixed + { + if ($value instanceof ScrollProp) { + $value->configureMergeIntent($this->request); + } + + $value = $this->resolveCallable($value); + + if ($value instanceof ProvidesInertiaProperty) { + $value = $value->toInertiaProperty(new PropertyContext($path, $siblings, $this->request)); + } + + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } + + if ($value instanceof PromiseInterface) { + $value = $value->wait(); + } + + if ($value instanceof Responsable) { + $response = $value->toResponse($this->request); + + if (method_exists($response, 'getData')) { + $value = $response->getData(true); + } + } + + return $value; + } + + /** + * Determine if the value is a prop type that requires + * further filtering or metadata collection. + */ + protected function isPropType(mixed $value): bool + { + return $value instanceof AlwaysProp + || $value instanceof Deferrable + || $value instanceof IgnoreFirstLoad + || $value instanceof Mergeable + || $value instanceof Onceable; + } + + /** + * Collect metadata for a prop that will be included in the response. + */ + protected function collectMetadata(mixed $prop, string $path): void + { + if ($prop instanceof Mergeable && $prop->shouldMerge()) { + $this->collectMergeableMetadata($path, $prop); + } + + if ($prop instanceof ScrollProp) { + $this->collectScrollMetadata($path, $prop); + } + + if ($prop instanceof Onceable && $prop->shouldResolveOnce()) { + $this->collectOnceMetadata($path, $prop); + } + } + + /** + * Collect the deferred prop and its group. + */ + protected function collectDeferredPropMetadata(string $path, Deferrable $prop): void + { + $this->deferredProps[$prop->group()][] = $path; + } + + /** + * Collect the merge strategy for a mergeable prop. + */ + protected function collectMergeableMetadata(string $path, Mergeable $prop): void + { + if (in_array($path, $this->resetProps)) { + return; + } + + if ($this->isPartial && ! $this->isIncludedInPartialMetadata($path)) { + return; + } + + if ($prop->shouldDeepMerge()) { + $this->deepMergeProps[] = $path; + } elseif ($prop->appendsAtRoot()) { + $this->mergeProps[] = $path; + } elseif ($prop->prependsAtRoot()) { + $this->prependProps[] = $path; + } else { + foreach ($prop->appendsAtPaths() as $appendPath) { + $this->mergeProps[] = "{$path}.{$appendPath}"; + } + foreach ($prop->prependsAtPaths() as $prependPath) { + $this->prependProps[] = "{$path}.{$prependPath}"; + } + } + + foreach ($prop->matchesOn() as $strategy) { + $this->matchPropsOn[] = "{$path}.{$strategy}"; + } + } + + /** + * Collect scroll pagination metadata. + * + * @param ScrollProp $prop + */ + protected function collectScrollMetadata(string $path, ScrollProp $prop): void + { + $this->scrollProps[$path] = [ + ...$prop->metadata(), + 'reset' => in_array($path, $this->resetProps), + ]; + } + + /** + * Collect once-prop metadata. + */ + protected function collectOnceMetadata(string $path, mixed $prop): void + { + if (! $prop instanceof Onceable || ! $prop->shouldResolveOnce()) { + return; + } + + if ($this->isPartial && ! $this->isIncludedInPartialMetadata($path)) { + return; + } + + $this->onceProps[$prop->getKey() ?? $path] = [ + 'prop' => $path, + 'expiresAt' => $prop->expiresAt(), + ]; + } + + /** + * Determine if the path should contribute metadata during a partial request. + */ + protected function isIncludedInPartialMetadata(string $path): bool + { + if ($this->only !== null && ! $this->matchesOnly($path)) { + return false; + } + + if ($this->except !== null && $this->matchesExcept($path)) { + return false; + } + + return true; + } + + /** + * Determine if the path matches or is a descendant of an "only" filter path. + */ + protected function matchesOnly(string $path): bool + { + foreach ($this->only as $onlyPath) { + if ($path === $onlyPath || str_starts_with($path, "{$onlyPath}.")) { + return true; + } + } + + return false; + } + + /** + * Determine if the path is an ancestor of an "only" filter path. + */ + protected function leadsToOnly(string $path): bool + { + foreach ($this->only as $onlyPath) { + if (str_starts_with($onlyPath, "{$path}.")) { + return true; + } + } + + return false; + } + + /** + * Determine if the path matches or is a descendant of an "except" filter path. + */ + protected function matchesExcept(string $path): bool + { + foreach ($this->except as $exceptPath) { + if ($path === $exceptPath || str_starts_with($path, "{$exceptPath}.")) { + return true; + } + } + + return false; + } + + /** + * Unpack top-level dot-notation keys into nested arrays. + * + * @param array $props + * @return array + */ + protected function unpackDotProps(array $props): array + { + foreach ($props as $key => $value) { + if (! is_string($key) || ! str_contains($key, '.')) { + continue; + } + + if ($value instanceof Closure) { + $value = App::call($value); + } + + if ($value instanceof Arrayable) { + $value = $value->toArray(); + } + + $this->ensurePathIsTraversable($props, $key); + Arr::set($props, $key, $value); + unset($props[$key]); + } + + return $props; + } + + /** + * Resolve closures and Arrayable values along the intermediate segments + * of a dot-notation path so that Arr::set can nest into them. + * + * @param array $props + */ + protected function ensurePathIsTraversable(array &$props, string $dotKey): void + { + $segments = explode('.', $dotKey); + array_pop($segments); + + $current = &$props; + + foreach ($segments as $segment) { + if (! isset($current[$segment])) { + return; + } + + if ($current[$segment] instanceof Closure) { + $current[$segment] = App::call($current[$segment]); + } + + if ($current[$segment] instanceof Arrayable) { + $current[$segment] = $current[$segment]->toArray(); + } + + if (! is_array($current[$segment])) { + return; + } + + $current = &$current[$segment]; + } + } + + /** + * Parse a comma-separated header value into an array. + * + * @return null|array + */ + protected function parseHeader(string $key): ?array + { + return array_filter(explode(',', $this->request->header($key, ''))) ?: null; + } +} From 16eef63112afd81cf5a96d6909484c4e98842eb6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:54 +0000 Subject: [PATCH 32/83] Stateless ResponseFactory with Context-backed per-request state All mutable per-request state (shared props, root view, version, encryption, URL resolver) lives in InertiaState via CoroutineContext. The factory itself is a stateless singleton cached by the facade. --- src/inertia/src/ResponseFactory.php | 456 ++++++++++++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 src/inertia/src/ResponseFactory.php diff --git a/src/inertia/src/ResponseFactory.php b/src/inertia/src/ResponseFactory.php new file mode 100644 index 000000000..0fe415b4c --- /dev/null +++ b/src/inertia/src/ResponseFactory.php @@ -0,0 +1,456 @@ + new InertiaState); + } + + /** + * Set the root view template for Inertia responses. This template + * serves as the HTML wrapper that contains the Inertia root element + * where the frontend application will be mounted. + */ + public function setRootView(string $name): void + { + $this->state()->rootView = $name; + } + + /** + * Share data across all Inertia responses. This data is automatically + * included with every response, making it ideal for user authentication + * state, flash messages, etc. + * + * @param array|Arrayable|ProvidesInertiaProperties|string $key + */ + public function share(mixed $key, mixed $value = null): void + { + $state = $this->state(); + + if (is_array($key)) { + $state->sharedProps = array_merge($state->sharedProps, $key); + } elseif ($key instanceof Arrayable) { + $state->sharedProps = array_merge($state->sharedProps, $key->toArray()); + } elseif ($key instanceof ProvidesInertiaProperties) { + $state->sharedProps = array_merge($state->sharedProps, [$key]); + } else { + Arr::set($state->sharedProps, $key, $value); + } + } + + /** + * Get the shared data for a given key. Returns all shared data if + * no key is provided, or the value for a specific key with an + * optional default fallback. + */ + public function getShared(?string $key = null, mixed $default = null): mixed + { + $sharedProps = $this->state()->sharedProps; + + if ($key) { + return Arr::get($sharedProps, $key, $default); + } + + return $sharedProps; + } + + /** + * Flush all shared data. + */ + public function flushShared(): void + { + $this->state()->sharedProps = []; + } + + /** + * Set the asset version. + */ + public function version(Closure|string|null $version): void + { + $this->state()->version = $version; + } + + /** + * Get the asset version. + */ + public function getVersion(): string + { + $version = $this->state()->version; + + $version = $version instanceof Closure + ? App::call($version) + : $version; + + return (string) $version; + } + + /** + * Set the URL resolver. + */ + public function resolveUrlUsing(?Closure $urlResolver = null): void + { + $this->state()->urlResolver = $urlResolver; + } + + /** + * Set the component transformer. + */ + public function transformComponentUsing(?Closure $componentTransformer = null): void + { + $this->state()->componentTransformer = $componentTransformer; + } + + /** + * Clear the browser history on the next visit. + */ + public function clearHistory(): void + { + session([SessionKey::CLEAR_HISTORY => true]); + } + + /** + * Preserve the URL fragment across the next redirect. + */ + public function preserveFragment(): void + { + session([SessionKey::PRESERVE_FRAGMENT => true]); + } + + /** + * Encrypt the browser history. + */ + public function encryptHistory(bool $encrypt = true): void + { + $this->state()->encryptHistory = $encrypt; + } + + /** + * Disable server-side rendering, optionally based on a condition. + */ + public function disableSsr(Closure|bool $condition = true): void + { + $gateway = app(Gateway::class); + + if (! $gateway instanceof DisablesSsr) { + throw new LogicException('The configured SSR gateway does not support disabling server-side rendering conditionally.'); + } + + $gateway->disable($condition); + } + + /** + * Exclude the given paths from server-side rendering. + * + * @param array|string $paths + */ + public function withoutSsr(array|string $paths): void + { + $gateway = app(Gateway::class); + + if (! $gateway instanceof ExcludesSsrPaths) { + throw new LogicException('The configured SSR gateway does not support excluding paths from server-side rendering.'); + } + + $gateway->except($paths); + } + + /** + * Create an optional property. + */ + public function optional(callable $callback): OptionalProp + { + return new OptionalProp($callback); + } + + /** + * Create a deferred property. + */ + public function defer(callable $callback, string $group = 'default'): DeferProp + { + return new DeferProp($callback, $group); + } + + /** + * Create a merge property. + */ + public function merge(mixed $value): MergeProp + { + return new MergeProp($value); + } + + /** + * Create a deep merge property. + */ + public function deepMerge(mixed $value): MergeProp + { + return (new MergeProp($value))->deepMerge(); + } + + /** + * Create an always property. + */ + public function always(mixed $value): AlwaysProp + { + return new AlwaysProp($value); + } + + /** + * Create a scroll property. + * + * @template T + * + * @param T $value + * @return ScrollProp + */ + public function scroll(mixed $value, string $wrapper = 'data', ProvidesScrollMetadata|callable|null $metadata = null): ScrollProp + { + return new ScrollProp($value, $wrapper, $metadata); + } + + /** + * Create a once property. + */ + public function once(callable $value): OnceProp + { + return new OnceProp($value); + } + + /** + * Create and share a once property. + */ + public function shareOnce(string $key, callable $callback): OnceProp + { + return tap(new OnceProp($callback), fn ($prop) => $this->share($key, $prop)); + } + + /** + * Find the component or fail. + * + * @throws ComponentNotFoundException + */ + protected function findComponentOrFail(string $component): void + { + try { + app('inertia.view-finder')->find($component); + } catch (InvalidArgumentException) { + throw new ComponentNotFoundException("Inertia page component [{$component}] not found."); + } + } + + /** + * Transform the component name. + */ + protected function transformComponent(mixed $component): mixed + { + $transformer = $this->state()->componentTransformer; + + if (! $transformer) { + return $component; + } + + return $transformer($component) ?? $component; + } + + /** + * Create an Inertia response. + * + * @param BackedEnum|string|UnitEnum $component + * @param array|Arrayable|ProvidesInertiaProperties $props + */ + public function render(mixed $component, mixed $props = []): Response + { + $component = $this->transformComponent($component); + + $component = match (true) { + $component instanceof BackedEnum => $component->value, + $component instanceof UnitEnum => $component->name, + default => $component, + }; + + if (! is_string($component)) { + throw new InvalidArgumentException('Component argument must be of type string or a string BackedEnum'); + } + + if (config('inertia.pages.ensure_pages_exist', false)) { + $this->findComponentOrFail($component); + } + + if ($props instanceof Arrayable) { + $props = $props->toArray(); + } elseif ($props instanceof ProvidesInertiaProperties) { + // Will be resolved in PropsResolver::resolvePropertyProviders() + $props = [$props]; + } + + $state = $this->state(); + + return new Response( + $component, + $state->sharedProps, + $props, + $state->rootView, + $this->getVersion(), + $state->encryptHistory ?? (bool) config('inertia.history.encrypt', false), + $state->urlResolver, + ); + } + + /** + * Create an Inertia location response. + */ + public function location(string|RedirectResponse $url): SymfonyResponse + { + if (Request::inertia()) { + return BaseResponse::make('', 409, [Header::LOCATION => $url instanceof RedirectResponse ? $url->getTargetUrl() : $url]); + } + + return $url instanceof RedirectResponse ? $url : Redirect::away($url); + } + + /** + * Register a callback to handle HTTP exceptions for Inertia requests. + * + * This is boot-time configuration — call once in a service provider's + * boot() method. The callback is stored on the exception handler singleton + * and applies to all requests for the worker lifetime. + */ + public function handleExceptionsUsing(callable $callback): void + { + /** @var mixed $handler */ + $handler = app(ExceptionHandlerContract::class); + + if (! $handler instanceof ExceptionHandler) { + if (app()->runningInConsole()) { + return; + } + + if (! method_exists($handler, 'respondUsing')) { + throw new LogicException('The bound exception handler does not have a `respondUsing` method.'); + } + } + + /** @var ExceptionHandler $handler */ + $handler->respondUsing(function ($response, $e, $request) use ($callback) { + $result = $callback(new ExceptionResponse( + $e, + $request, + $response, + app(Router::class), + app(Kernel::class), + )); + + if ($result instanceof ExceptionResponse) { + return $result->toResponse($request); + } + + return $result ?? $response; + }); + } + + /** + * Flash data to be included with the next response. Unlike regular props, + * flash data is not persisted in the browser's history state, making it + * ideal for one-time notifications like toasts or highlights. + * + * @param array|BackedEnum|string|UnitEnum $key + */ + public function flash(BackedEnum|UnitEnum|string|array $key, mixed $value = null): self + { + $flash = $key; + + if (! is_array($key)) { + $key = match (true) { + $key instanceof BackedEnum => $key->value, + $key instanceof UnitEnum => $key->name, + default => $key, + }; + + $flash = [$key => $value]; + } + + session()->flash(SessionKey::FLASH_DATA, [ + ...$this->getFlashed(), + ...$flash, + ]); + + return $this; + } + + /** + * Create a new redirect response to the previous location. + * + * @param array $headers + */ + public function back(int $status = 302, array $headers = [], mixed $fallback = false): RedirectResponse + { + return Redirect::back($status, $headers, $fallback); + } + + /** + * Retrieve the flashed data from the session. + * + * @return array + */ + public function getFlashed(?HttpRequest $request = null): array + { + $request ??= request(); + + return $request->hasSession() ? $request->session()->get(SessionKey::FLASH_DATA, []) : []; + } + + /** + * Retrieve and remove the flashed data from the session. + * + * @return array + */ + public function pullFlashed(?HttpRequest $request = null): array + { + $request ??= request(); + + return $request->hasSession() ? $request->session()->pull(SessionKey::FLASH_DATA, []) : []; + } + + /** + * Reset the per-request Inertia state. + */ + public static function flushState(): void + { + CoroutineContext::forget(InertiaState::CONTEXT_KEY); + } +} From a874781a36d43efbeef05f770fef477b820aa921 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:01:54 +0000 Subject: [PATCH 33/83] Inertia Response with SSR state via CoroutineContext --- src/inertia/src/Response.php | 272 +++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 src/inertia/src/Response.php diff --git a/src/inertia/src/Response.php b/src/inertia/src/Response.php new file mode 100644 index 000000000..46e166575 --- /dev/null +++ b/src/inertia/src/Response.php @@ -0,0 +1,272 @@ + + */ + protected array $props; + + /** + * The name of the root view. + */ + protected string $rootView; + + /** + * The asset version. + */ + protected string $version; + + /** + * Indicates if the browser history should be cleared. + */ + protected bool $clearHistory; + + /** + * Indicates if the URL fragment should be preserved across redirects. + */ + protected bool $preserveFragment; + + /** + * Indicates if the browser history should be encrypted. + */ + protected bool $encryptHistory; + + /** + * The view data. + * + * @var array + */ + protected array $viewData = []; + + /** + * The URL resolver callback. + */ + protected ?Closure $urlResolver = null; + + /** + * The shared properties (before merge with page props). + * + * @var array + */ + protected array $sharedProps = []; + + /** + * Create a new Inertia response instance. + * + * @param array $sharedProps + * @param array $props + */ + public function __construct( + string $component, + array $sharedProps, + array $props, + string $rootView = 'app', + string $version = '', + bool $encryptHistory = false, + ?Closure $urlResolver = null, + ) { + $this->component = $component; + $this->sharedProps = $sharedProps; + $this->props = $props; + $this->rootView = $rootView; + $this->version = $version; + $this->clearHistory = session()->pull(SessionKey::CLEAR_HISTORY, false); + $this->preserveFragment = session()->pull(SessionKey::PRESERVE_FRAGMENT, false); + $this->encryptHistory = $encryptHistory; + $this->urlResolver = $urlResolver; + } + + /** + * Add additional properties to the page. + * + * @param array|ProvidesInertiaProperties|string $key + * @param mixed $value + * @return $this + */ + public function with($key, $value = null): self + { + if ($key instanceof ProvidesInertiaProperties) { + $this->props[] = $key; + } elseif (is_array($key)) { + $this->props = array_merge($this->props, $key); + } else { + $this->props[$key] = $value; + } + + return $this; + } + + /** + * Add additional data to the view. + * + * @param array|string $key + * @param mixed $value + * @return $this + */ + public function withViewData($key, $value = null): self + { + if (is_array($key)) { + $this->viewData = array_merge($this->viewData, $key); + } else { + $this->viewData[$key] = $value; + } + + return $this; + } + + /** + * Set the root view. + * + * @return $this + */ + public function rootView(string $rootView): self + { + $this->rootView = $rootView; + + return $this; + } + + /** + * Add flash data to the response. + * + * @param array|BackedEnum|string|UnitEnum $key + * @return $this + */ + public function flash(BackedEnum|UnitEnum|string|array $key, mixed $value = null): self + { + Inertia::flash($key, $value); + + return $this; + } + + /** + * Create an HTTP response that represents the object. + */ + public function toResponse(Request $request): SymfonyResponse + { + $resolver = new PropsResolver($request, $this->component); + [$resolvedProps, $resolvedMetadata] = $resolver->resolve($this->sharedProps, $this->props); + + $page = array_merge( + [ + 'component' => $this->component, + 'props' => $resolvedProps, + 'url' => $this->getUrl($request), + 'version' => $this->version, + ], + $resolvedMetadata, + $this->resolveClearHistory($request), + $this->resolveEncryptHistory($request), + $this->resolveFlashData($request), + $this->resolvePreserveFragment($request), + ); + + if ($request->header(Header::INERTIA)) { + return new JsonResponse($page, 200, [Header::INERTIA => 'true']); + } + + CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState)->page = $page; + + return ResponseFactory::view($this->rootView, $this->viewData + ['page' => $page]); + } + + /** + * Resolve the clear history flag. + * + * @return array + */ + protected function resolveClearHistory(Request $request): array + { + return $this->clearHistory ? ['clearHistory' => true] : []; + } + + /** + * Resolve the encrypt history flag. + * + * @return array + */ + protected function resolveEncryptHistory(Request $request): array + { + return $this->encryptHistory ? ['encryptHistory' => true] : []; + } + + /** + * Resolve flash data from the session. + * + * @return array + */ + protected function resolveFlashData(Request $request): array + { + $flash = Inertia::pullFlashed($request); + + return $flash ? ['flash' => $flash] : []; + } + + /** + * Resolve the preserve fragment flag from the session. + * + * @return array + */ + protected function resolvePreserveFragment(Request $request): array + { + return $this->preserveFragment ? ['preserveFragment' => true] : []; + } + + /** + * Get the URL from the request while preserving the trailing slash. + */ + protected function getUrl(Request $request): string + { + $urlResolver = $this->urlResolver ?? function (Request $request) { + $url = Str::start(Str::after($request->fullUrl(), $request->getSchemeAndHttpHost()), '/'); + + $rawUri = Str::before($request->getRequestUri(), '?'); + + return Str::endsWith($rawUri, '/') ? $this->finishUrlWithTrailingSlash($url) : $url; + }; + + return App::call($urlResolver, ['request' => $request]); + } + + /** + * Ensure the URL has a trailing slash before the query string. + */ + protected function finishUrlWithTrailingSlash(string $url): string + { + // Make sure the relative URL ends with a trailing slash and re-append the query string if it exists. + $urlWithoutQueryWithTrailingSlash = Str::finish(Str::before($url, '?'), '/'); + + return str_contains($url, '?') + ? $urlWithoutQueryWithTrailingSlash . '?' . Str::after($url, '?') + : $urlWithoutQueryWithTrailingSlash; + } +} From 13c4ce69e4d0d1a70f484dc79c142c514808e9f8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:07 +0000 Subject: [PATCH 34/83] Inertia route controller --- src/inertia/src/Controller.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/inertia/src/Controller.php diff --git a/src/inertia/src/Controller.php b/src/inertia/src/Controller.php new file mode 100644 index 000000000..4b1b49163 --- /dev/null +++ b/src/inertia/src/Controller.php @@ -0,0 +1,22 @@ +route()->defaults['component'], + $request->route()->defaults['props'] + ); + } +} From 2605486c69906c3e0b9a8ebcfc03e21acda02452 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:07 +0000 Subject: [PATCH 35/83] Inertia middleware with worker-lifetime version caching Asset version hash is computed once per worker and cached in a static property, eliminating filesystem I/O on every request. --- src/inertia/src/Middleware.php | 271 +++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 src/inertia/src/Middleware.php diff --git a/src/inertia/src/Middleware.php b/src/inertia/src/Middleware.php new file mode 100644 index 000000000..5d6ac1693 --- /dev/null +++ b/src/inertia/src/Middleware.php @@ -0,0 +1,271 @@ + + */ + protected array $withoutSsr = []; + + /** + * The cached asset version for this worker. + */ + private static ?string $cachedVersion = null; + + /** + * Whether the version has been computed for this worker. + */ + private static bool $versionComputed = false; + + /** + * Determine the current asset version. + * + * The result is cached for the worker lifetime to avoid + * repeated filesystem I/O on every request. + */ + public function version(Request $request): ?string + { + if (! self::$versionComputed) { + self::$cachedVersion = $this->computeVersion(); + self::$versionComputed = true; + } + + return self::$cachedVersion; + } + + /** + * Compute the asset version from the manifest file. + */ + private function computeVersion(): ?string + { + if (config('app.asset_url')) { + return hash('xxh128', (string) config('app.asset_url')); + } + + if (file_exists($manifest = public_path('build/manifest.json'))) { + return hash_file('xxh128', $manifest) ?: null; + } + + if (file_exists($manifest = public_path('mix-manifest.json'))) { + return hash_file('xxh128', $manifest) ?: null; + } + + return null; + } + + /** + * Define the props that are shared by default. + * + * @return array + */ + public function share(Request $request): array + { + return [ + 'errors' => Inertia::always($this->resolveValidationErrors($request)), + ]; + } + + /** + * Define the props that are shared once and remembered across navigations. + * + * @return array + */ + public function shareOnce(Request $request): array + { + return []; + } + + /** + * Set the root template that is loaded on the first page visit. + */ + public function rootView(Request $request): string + { + return $this->rootView; + } + + /** + * Define a callback that returns the relative URL. + */ + public function urlResolver(): ?Closure + { + return null; + } + + /** + * Handle the incoming request. + */ + public function handle(Request $request, Closure $next): Response + { + Inertia::version(function () use ($request) { + return $this->version($request); + }); + + Inertia::share($this->share($request)); + + foreach ($this->shareOnce($request) as $key => $value) { + if ($value instanceof OnceProp) { + Inertia::share($key, $value); + } else { + Inertia::shareOnce($key, $value); + } + } + + Inertia::setRootView($this->rootView($request)); + + if ($urlResolver = $this->urlResolver()) { + Inertia::resolveUrlUsing($urlResolver); + } + + $ssrGateway = app(Gateway::class); + + if (! empty($this->withoutSsr) && $ssrGateway instanceof ExcludesSsrPaths) { + $ssrGateway->except($this->withoutSsr); + } + + $response = $next($request); + $response->headers->set('Vary', Header::INERTIA); + + if ($isRedirect = $response->isRedirect()) { + $this->reflash($request); + } + + if (! $request->header(Header::INERTIA)) { + return $response; + } + + if ($request->method() === 'GET' && $request->header(Header::VERSION, '') !== Inertia::getVersion()) { + $response = $this->onVersionChange($request, $response); + } + + if ($response->isOk() && empty($response->getContent())) { + $response = $this->onEmptyResponse($request, $response); + } + + if ($response->getStatusCode() === 302 && in_array($request->method(), ['PUT', 'PATCH', 'DELETE'])) { + $response->setStatusCode(303); + } + + if ($isRedirect && $this->redirectHasFragment($response) && ! $request->prefetch()) { + $response = $this->onRedirectWithFragment($request, $response); + } + + return $response; + } + + /** + * Determine if the redirect response contains a URL fragment. + */ + protected function redirectHasFragment(Response $response): bool + { + return str_contains($response->headers->get('Location', ''), '#'); + } + + /** + * Reflash the session data for the next request. + */ + protected function reflash(Request $request): void + { + if ($flashed = Inertia::getFlashed($request)) { + $request->session()->flash(SessionKey::FLASH_DATA, $flashed); + } + } + + /** + * Handle empty responses. + */ + public function onEmptyResponse(Request $request, Response $response): Response + { + return Redirect::back(); + } + + /** + * Handle redirects with URL fragments. + */ + public function onRedirectWithFragment(Request $request, Response $response): Response + { + return response('', 409, [ + Header::REDIRECT => $response->headers->get('Location'), + ]); + } + + /** + * Handle version changes. + */ + public function onVersionChange(Request $request, Response $response): Response + { + if ($request->hasSession()) { + /** @var Store $session */ + $session = $request->session(); + $session->reflash(); + } + + return Inertia::location($request->fullUrl()); + } + + /** + * Resolve validation errors for client-side use. + */ + public function resolveValidationErrors(Request $request): object + { + if (! $request->hasSession() || ! $request->session()->has('errors')) { + return (object) []; + } + + /** @var array $bags */ + $bags = $request->session()->get('errors')->getBags(); + + return (object) collect($bags)->map(function ($bag) { + return (object) collect($bag->messages())->map(function ($errors) { + return $this->withAllErrors ? $errors : $errors[0]; + })->toArray(); + })->pipe(function ($bags) use ($request) { + if ($bags->has('default') && $request->header(Header::ERROR_BAG)) { + return [$request->header(Header::ERROR_BAG) => $bags->get('default')]; + } + + if ($bags->has('default')) { + return $bags->get('default'); + } + + return $bags->toArray(); + }); + } + + /** + * Reset the cached version state. + */ + public static function flushState(): void + { + self::$cachedVersion = null; + self::$versionComputed = false; + } +} From 8f08481303e145de7fb061bd0a83c2c2d9e9ca09 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:07 +0000 Subject: [PATCH 36/83] History encryption middleware for Inertia --- src/inertia/src/EncryptHistoryMiddleware.php | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/inertia/src/EncryptHistoryMiddleware.php diff --git a/src/inertia/src/EncryptHistoryMiddleware.php b/src/inertia/src/EncryptHistoryMiddleware.php new file mode 100644 index 000000000..3ec2a615e --- /dev/null +++ b/src/inertia/src/EncryptHistoryMiddleware.php @@ -0,0 +1,24 @@ + Date: Mon, 13 Apr 2026 13:02:07 +0000 Subject: [PATCH 37/83] Inertia middleware subclasses (EncryptHistory, EnsureGetOnRedirect) --- src/inertia/src/Middleware/EncryptHistory.php | 11 +++++++ .../src/Middleware/EnsureGetOnRedirect.php | 32 +++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 src/inertia/src/Middleware/EncryptHistory.php create mode 100644 src/inertia/src/Middleware/EnsureGetOnRedirect.php diff --git a/src/inertia/src/Middleware/EncryptHistory.php b/src/inertia/src/Middleware/EncryptHistory.php new file mode 100644 index 000000000..fd67b4da3 --- /dev/null +++ b/src/inertia/src/Middleware/EncryptHistory.php @@ -0,0 +1,11 @@ +getStatusCode() === 302 + && $request->header(Header::INERTIA) + && in_array($request->method(), ['PUT', 'PATCH', 'DELETE']) + ) { + $response->setStatusCode(303); + } + + return $response; + } +} From 056434e405d10ee97b3b64e03a3d3f1e0e4e37b2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:20 +0000 Subject: [PATCH 38/83] Exception response rendering for Inertia error pages --- src/inertia/src/ExceptionResponse.php | 168 ++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/inertia/src/ExceptionResponse.php diff --git a/src/inertia/src/ExceptionResponse.php b/src/inertia/src/ExceptionResponse.php new file mode 100644 index 000000000..c55544e75 --- /dev/null +++ b/src/inertia/src/ExceptionResponse.php @@ -0,0 +1,168 @@ + */ + protected array $props = []; + + protected bool $includeSharedData = false; + + protected ?string $rootView = null; + + /** @var null|class-string */ + protected ?string $middlewareClass = null; + + public function __construct( + public readonly Throwable $exception, + public readonly Request $request, + public readonly Response $response, + protected readonly Router $router, + protected readonly KernelContract $kernel, + ) { + } + + /** + * @param array $props + */ + public function render(string $component, array $props = []): static + { + $this->component = $component; + $this->props = $props; + + return $this; + } + + /** + * @param class-string $middlewareClass + */ + public function usingMiddleware(string $middlewareClass): static + { + $this->middlewareClass = $middlewareClass; + + return $this; + } + + public function withSharedData(): static + { + $this->includeSharedData = true; + + return $this; + } + + public function rootView(string $rootView): static + { + $this->rootView = $rootView; + + return $this; + } + + public function statusCode(): int + { + return $this->response->getStatusCode(); + } + + /** + * Create an HTTP response that represents the object. + */ + public function toResponse(Request $request): Response + { + if ($this->component === null) { + return $this->response; + } + + $middleware = $this->resolveMiddleware(); + + if ($middleware) { + Inertia::version(fn () => $middleware->version($this->request)); + Inertia::setRootView($this->rootView ?? $middleware->rootView($this->request)); + } elseif ($this->rootView) { + Inertia::setRootView($this->rootView); + } + + if ($this->includeSharedData && $middleware) { + Inertia::share($middleware->share($this->request)); + + foreach ($middleware->shareOnce($this->request) as $key => $value) { + if ($value instanceof OnceProp) { + Inertia::share($key, $value); + } else { + Inertia::shareOnce($key, $value); + } + } + } + + return Inertia::render($this->component, $this->props) + ->toResponse($this->request) + ->setStatusCode($this->response->getStatusCode()); + } + + protected function resolveMiddleware(): ?Middleware + { + if ($this->middlewareClass) { + return app($this->middlewareClass); + } + + $class = $this->resolveMiddlewareFromRoute() ?? $this->resolveMiddlewareFromKernel(); + + if ($class) { + return app($class); + } + + return null; + } + + /** + * @return null|class-string + */ + protected function resolveMiddlewareFromRoute(): ?string + { + $route = $this->request->route(); + + if (! $route) { + return null; + } + + foreach ($this->router->gatherRouteMiddleware($route) as $middleware) { + if (! is_string($middleware)) { + continue; + } + + $class = head(explode(':', $middleware)); + + if (is_a($class, Middleware::class, true)) { + return $class; + } + } + + return null; + } + + /** + * @return null|class-string + */ + protected function resolveMiddlewareFromKernel(): ?string + { + foreach ($this->kernel->getMiddlewareGroups() as $group) { + foreach ($group as $middleware) { + if (is_string($middleware) && is_a($middleware, Middleware::class, true)) { + return $middleware; + } + } + } + + return null; + } +} From 0adef486a6e08406f8e11bfefcdb5e6d76dc28e3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:20 +0000 Subject: [PATCH 39/83] Blade directives (@inertia, @inertiaHead) with Context-based SSR dispatch --- src/inertia/src/Directive.php | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/inertia/src/Directive.php diff --git a/src/inertia/src/Directive.php b/src/inertia/src/Directive.php new file mode 100644 index 000000000..7d94cd764 --- /dev/null +++ b/src/inertia/src/Directive.php @@ -0,0 +1,48 @@ +body; + } else { + ?>
'; + + return implode(' ', array_map('trim', explode("\n", $template))); + } + + /** + * Compile the "@inertiaHead" Blade directive. This directive renders the + * head content for SSR responses, including meta tags, title, and other + * head elements from the server-side render. + */ + public static function compileHead(string $expression = ''): string + { + $template = 'head; + } + ?>'; + + return implode(' ', array_map('trim', explode("\n", $template))); + } +} From 957667916df1fd7af57f741b6278338e78385690 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:32 +0000 Subject: [PATCH 40/83] Blade view components for Inertia SSR rendering --- src/inertia/src/View/Components/App.php | 46 ++++++++++++++++++++++++ src/inertia/src/View/Components/Head.php | 42 ++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/inertia/src/View/Components/App.php create mode 100644 src/inertia/src/View/Components/Head.php diff --git a/src/inertia/src/View/Components/App.php b/src/inertia/src/View/Components/App.php new file mode 100644 index 000000000..bfd3358c6 --- /dev/null +++ b/src/inertia/src/View/Components/App.php @@ -0,0 +1,46 @@ + new InertiaState); + + if (! $state->ssrDispatched) { + $state->ssrDispatched = true; + $state->ssrResponse = app(Gateway::class)->dispatch($state->page); + } + + $this->response = $state->ssrResponse; + $this->pageJson = (string) json_encode($state->page); + } + + /** + * Render the component. + */ + public function render(): string + { + return <<<'blade' +@if($response) +{!! $response->body !!} +@else +
+@endif +blade; + } +} diff --git a/src/inertia/src/View/Components/Head.php b/src/inertia/src/View/Components/Head.php new file mode 100644 index 000000000..cfe802420 --- /dev/null +++ b/src/inertia/src/View/Components/Head.php @@ -0,0 +1,42 @@ + new InertiaState); + + if (! $state->ssrDispatched) { + $state->ssrDispatched = true; + $state->ssrResponse = app(Gateway::class)->dispatch($state->page); + } + + $this->response = $state->ssrResponse; + } + + /** + * Render the component. + */ + public function render(): string + { + return <<<'blade' +@if($response) +{!! $response->head !!} +@else +{!! $slot !!} +@endif +blade; + } +} From 0d2891badf525a5ac33c56cff9de66a338363cc8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:32 +0000 Subject: [PATCH 41/83] AssertableInertia test assertion class --- src/inertia/src/Testing/AssertableInertia.php | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 src/inertia/src/Testing/AssertableInertia.php diff --git a/src/inertia/src/Testing/AssertableInertia.php b/src/inertia/src/Testing/AssertableInertia.php new file mode 100644 index 000000000..b65ff450d --- /dev/null +++ b/src/inertia/src/Testing/AssertableInertia.php @@ -0,0 +1,296 @@ +> + */ + private array $deferredProps; + + /** + * The Flash Data (if any). + * + * @var array + */ + private array $flash; + + /** + * Create an AssertableInertia instance from a test response. + * + * @param TestResponse $response + */ + public static function fromTestResponse(TestResponse $response): self + { + try { + $response->assertViewHas('page'); + $page = json_decode(json_encode($response->viewData('page')), true); + + PHPUnit::assertIsArray($page); + PHPUnit::assertArrayHasKey('component', $page); + PHPUnit::assertArrayHasKey('props', $page); + PHPUnit::assertArrayHasKey('url', $page); + PHPUnit::assertArrayHasKey('version', $page); + } catch (AssertionFailedError $e) { + PHPUnit::fail('Not a valid Inertia response.'); + } + + $instance = static::fromArray($page['props']); + $instance->component = $page['component']; + $instance->url = $page['url']; + $instance->version = $page['version']; + $instance->encryptHistory = isset($page['encryptHistory']); + $instance->clearHistory = isset($page['clearHistory']); + $instance->deferredProps = $page['deferredProps'] ?? []; + $instance->flash = $page['flash'] ?? []; + + return $instance; + } + + /** + * Assert that the page uses the given component. + * + * @param null|bool $shouldExist + */ + public function component(?string $value = null, $shouldExist = null): self + { + PHPUnit::assertSame($value, $this->component, 'Unexpected Inertia page component.'); + + if ($shouldExist || (is_null($shouldExist) && config('inertia.testing.ensure_pages_exist', true))) { + try { + app('inertia.view-finder')->find($value); + } catch (InvalidArgumentException $exception) { + PHPUnit::fail(sprintf('Inertia page component file [%s] does not exist.', $value)); + } + } + + return $this; + } + + /** + * Assert that the current page URL matches the expected value. + */ + public function url(string $value): self + { + PHPUnit::assertSame($value, $this->url, 'Unexpected Inertia page url.'); + + return $this; + } + + /** + * Assert that the current asset version matches the expected value. + */ + public function version(string $value): self + { + PHPUnit::assertSame($value, $this->version, 'Unexpected Inertia asset version.'); + + return $this; + } + + /** + * Load the deferred props for the given groups and perform assertions on the response. + * + * @param array|Closure|string $groupsOrCallback + */ + public function loadDeferredProps(Closure|array|string $groupsOrCallback, ?Closure $callback = null): self + { + $callback = is_callable($groupsOrCallback) ? $groupsOrCallback : $callback; + + $groups = is_callable($groupsOrCallback) ? array_keys($this->deferredProps) : Arr::wrap($groupsOrCallback); + + $props = collect($groups)->flatMap(function ($group) { + return $this->deferredProps[$group] ?? []; + })->implode(','); + + return $this->reloadOnly($props, $callback); + } + + /** + * Reload the Inertia page and perform assertions on the response. + * + * @param null|array|string $only + * @param null|array|string $except + */ + public function reload(?Closure $callback = null, array|string|null $only = null, array|string|null $except = null): self + { + if (is_array($only)) { + $only = implode(',', $only); + } + + if (is_array($except)) { + $except = implode(',', $except); + } + + $reloadRequest = new ReloadRequest( + $this->url, + $this->component, + $this->version, + $only, + $except, + ); + + $assertable = AssertableInertia::fromTestResponse($reloadRequest()); + + // Make sure we get the same data as the original request. + $assertable->component($this->component); + $assertable->url($this->url); + $assertable->version($this->version); + + if ($callback) { + $callback($assertable); + } + + return $this; + } + + /** + * Reload the Inertia page as a partial request with only the specified props. + * + * @param array|string $only + */ + public function reloadOnly(array|string $only, ?Closure $callback = null): self + { + return $this->reload(only: $only, callback: function (AssertableInertia $assertable) use ($only, $callback) { + $props = is_array($only) ? $only : explode(',', $only); + + $assertable->hasAll($props); + + if ($callback) { + $callback($assertable); + } + }); + } + + /** + * Reload the Inertia page as a partial request excluding the specified props. + * + * @param array|string $except + */ + public function reloadExcept(array|string $except, ?Closure $callback = null): self + { + return $this->reload(except: $except, callback: function (AssertableInertia $assertable) use ($except, $callback) { + $props = is_array($except) ? $except : explode(',', $except); + + $assertable->missingAll($props); + + if ($callback) { + $callback($assertable); + } + }); + } + + /** + * Assert that the Flash Data contains the given key, optionally with the expected value. + */ + public function hasFlash(string $key, mixed $expected = null): self + { + func_num_args() > 1 + ? static::assertFlashHas($this->flash, $key, $expected) + : static::assertFlashHas($this->flash, $key); + + return $this; + } + + /** + * Assert that the Flash Data does not contain the given key. + */ + public function missingFlash(string $key): self + { + static::assertFlashMissing($this->flash, $key); + + return $this; + } + + /** + * Assert that the given flash data array contains the given key, optionally with the expected value. + * + * @param array $flash + */ + public static function assertFlashHas(array $flash, string $key, mixed $expected = null): void + { + PHPUnit::assertTrue( + Arr::has($flash, $key), + sprintf('Inertia Flash Data is missing key [%s].', $key) + ); + + if (func_num_args() > 2) { + PHPUnit::assertSame( + $expected, + Arr::get($flash, $key), + sprintf('Inertia Flash Data [%s] does not match expected value.', $key) + ); + } + } + + /** + * Assert that the given flash data array does not contain the given key. + * + * @param array $flash + */ + public static function assertFlashMissing(array $flash, string $key): void + { + PHPUnit::assertFalse( + Arr::has($flash, $key), + sprintf('Inertia Flash Data has unexpected key [%s].', $key) + ); + } + + /** + * Convert the instance to an array. + * + * @return array + */ + public function toArray(): array + { + return array_merge( + [ + 'component' => $this->component, + 'props' => $this->prop(), + 'url' => $this->url, + 'version' => $this->version, + 'flash' => $this->flash, + ], + $this->encryptHistory ? ['encryptHistory' => true] : [], + $this->clearHistory ? ['clearHistory' => true] : [], + ); + } +} From 7657ce867a2424b6fa97028926bb1e8e86aa2db8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:33 +0000 Subject: [PATCH 42/83] ReloadRequest test helper for partial reload assertions --- src/inertia/src/Testing/ReloadRequest.php | 52 +++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/inertia/src/Testing/ReloadRequest.php diff --git a/src/inertia/src/Testing/ReloadRequest.php b/src/inertia/src/Testing/ReloadRequest.php new file mode 100644 index 000000000..2ab79dc4e --- /dev/null +++ b/src/inertia/src/Testing/ReloadRequest.php @@ -0,0 +1,52 @@ +app ??= app(); + } + + /** + * Execute the reload request with appropriate Inertia headers. + * + * @return TestResponse + */ + public function __invoke(): TestResponse + { + $headers = [Header::VERSION => $this->version]; + + if (! blank($this->only)) { + $headers[Header::PARTIAL_COMPONENT] = $this->component; + $headers[Header::PARTIAL_ONLY] = $this->only; + } + + if (! blank($this->except)) { + $headers[Header::PARTIAL_COMPONENT] = $this->component; + $headers[Header::PARTIAL_EXCEPT] = $this->except; + } + + return $this->get($this->url, $headers); + } +} From 53d1dbd32983a86b03f289e38c9fb2dcbb3e61a4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:33 +0000 Subject: [PATCH 43/83] TestResponse macros for Inertia assertions --- .../src/Testing/TestResponseMacros.php | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/inertia/src/Testing/TestResponseMacros.php diff --git a/src/inertia/src/Testing/TestResponseMacros.php b/src/inertia/src/Testing/TestResponseMacros.php new file mode 100644 index 000000000..609f21b85 --- /dev/null +++ b/src/inertia/src/Testing/TestResponseMacros.php @@ -0,0 +1,87 @@ +toArray(); + }; + } + + /** + * Register the 'inertiaProps' macro for TestResponse. + */ + public function inertiaProps(): Closure + { + return function (?string $propName = null) { + /** @phpstan-ignore-next-line */ + $page = AssertableInertia::fromTestResponse($this)->toArray(); + + return Arr::get($page['props'], $propName); + }; + } + + /** + * Register the 'assertInertiaFlash' macro for TestResponse. + */ + public function assertInertiaFlash(): Closure + { + return function (string $key, mixed $expected = null) { + /** @phpstan-ignore-next-line */ + $flash = $this->session()->get(SessionKey::FLASH_DATA, []); + + func_num_args() > 1 + ? AssertableInertia::assertFlashHas($flash, $key, $expected) + : AssertableInertia::assertFlashHas($flash, $key); + + return $this; + }; + } + + /** + * Register the 'assertInertiaFlashMissing' macro for TestResponse. + */ + public function assertInertiaFlashMissing(): Closure + { + return function (string $key) { + /** @phpstan-ignore-next-line */ + $flash = $this->session()->get(SessionKey::FLASH_DATA, []); + + AssertableInertia::assertFlashMissing($flash, $key); + + return $this; + }; + } +} From 6bd8605aeb7ad1e5e09e2cf02b7ea5b406739f9d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:51 +0000 Subject: [PATCH 44/83] Inertia facade --- src/inertia/src/Inertia.php | 51 +++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/inertia/src/Inertia.php diff --git a/src/inertia/src/Inertia.php b/src/inertia/src/Inertia.php new file mode 100644 index 000000000..9e2f2b694 --- /dev/null +++ b/src/inertia/src/Inertia.php @@ -0,0 +1,51 @@ +|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Inertia\ProvidesInertiaProperties $key, mixed $value = null) + * @method static mixed getShared(string|null $key = null, mixed $default = null) + * @method static void flushShared() + * @method static void version(\Closure|string|null $version) + * @method static string getVersion() + * @method static void resolveUrlUsing(\Closure|null $urlResolver = null) + * @method static void transformComponentUsing(\Closure|null $componentTransformer = null) + * @method static void clearHistory() + * @method static void preserveFragment() + * @method static void encryptHistory(bool $encrypt = true) + * @method static void disableSsr(\Closure|bool $condition = true) + * @method static void withoutSsr(array|string $paths) + * @method static \Hypervel\Inertia\OptionalProp optional(callable $callback) + * @method static \Hypervel\Inertia\DeferProp defer(callable $callback, string $group = 'default') + * @method static \Hypervel\Inertia\MergeProp merge(mixed $value) + * @method static \Hypervel\Inertia\MergeProp deepMerge(mixed $value) + * @method static \Hypervel\Inertia\AlwaysProp always(mixed $value) + * @method static \Hypervel\Inertia\ScrollProp scroll(mixed $value, string $wrapper = 'data', \Hypervel\Inertia\ProvidesScrollMetadata|callable|null $metadata = null) + * @method static \Hypervel\Inertia\OnceProp once(callable $value) + * @method static \Hypervel\Inertia\OnceProp shareOnce(string $key, callable $callback) + * @method static \Hypervel\Inertia\Response render(\BackedEnum|\UnitEnum|string $component, array|\Hypervel\Contracts\Support\Arrayable|\Hypervel\Inertia\ProvidesInertiaProperties $props = []) + * @method static \Symfony\Component\HttpFoundation\Response location(string|\Symfony\Component\HttpFoundation\RedirectResponse $url) + * @method static void handleExceptionsUsing(callable $callback) + * @method static \Hypervel\Inertia\ResponseFactory flash(\BackedEnum|\UnitEnum|string|array $key, mixed $value = null) + * @method static \Symfony\Component\HttpFoundation\RedirectResponse back(int $status = 302, array $headers = [], mixed $fallback = false) + * @method static array getFlashed(\Hypervel\Http\Request|null $request = null) + * @method static array pullFlashed(\Hypervel\Http\Request|null $request = null) + * @method static void macro(string $name, object|callable $macro) + * @method static void mixin(object $mixin, bool $replace = true) + * @method static bool hasMacro(string $name) + * @method static void flushMacros() + * + * @see \Hypervel\Inertia\ResponseFactory + */ +class Inertia extends Facade +{ + protected static function getFacadeAccessor(): string + { + return ResponseFactory::class; + } +} From 8a1c7d1bfd3f4b92f9406906afd68d4b1b6d452d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:51 +0000 Subject: [PATCH 45/83] InertiaServiceProvider with auto-singleton ResponseFactory/HttpGateway Registers Gateway interface binding, Blade components/directives, request/router/redirect macros, middleware aliases, console commands, and the inertia.view-finder for page existence checking. --- src/inertia/src/InertiaServiceProvider.php | 183 +++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/inertia/src/InertiaServiceProvider.php diff --git a/src/inertia/src/InertiaServiceProvider.php b/src/inertia/src/InertiaServiceProvider.php new file mode 100644 index 000000000..86e202ef3 --- /dev/null +++ b/src/inertia/src/InertiaServiceProvider.php @@ -0,0 +1,183 @@ +app->singleton(Gateway::class, HttpGateway::class); + + $this->mergeConfigFrom( + __DIR__ . '/../config/inertia.php', + 'inertia' + ); + + $this->registerBladeComponents(); + $this->registerBladeDirectives(); + $this->registerRedirectMacro(); + $this->registerRequestMacro(); + $this->registerRouterMacro(); + $this->registerTestingMacros(); + $this->registerMiddleware(); + + $this->app->bind('inertia.view-finder', function ($app) { + return new FileViewFinder( + $app['files'], + $app['config']->get('inertia.pages.paths'), + $app['config']->get('inertia.pages.extensions') + ); + }); + } + + /** + * Boot the service provider. + */ + public function boot(): void + { + $this->registerConsoleCommands(); + $this->pushRedirectMiddleware(); + + $this->publishes([ + __DIR__ . '/../config/inertia.php' => config_path('inertia.php'), + ]); + } + + /** + * Register the global redirect middleware for Inertia requests. + */ + protected function pushRedirectMiddleware(): void + { + $this->callAfterResolving(HttpKernelContract::class, function ($kernel) { + if ($kernel instanceof Kernel) { + $kernel->pushMiddleware(Middleware\EnsureGetOnRedirect::class); + } + }); + } + + /** + * Register Blade components for rendering Inertia head and body content. + */ + protected function registerBladeComponents(): void + { + $this->callAfterResolving('blade.compiler', function () { + Blade::componentNamespace('Hypervel\Inertia\View\Components', 'inertia'); + }); + } + + /** + * Register @inertia and @inertiaHead directives for rendering the Inertia + * root element and SSR head content in Blade templates. + */ + protected function registerBladeDirectives(): void + { + $this->callAfterResolving('blade.compiler', function ($blade) { + $blade->directive('inertia', [Directive::class, 'compile']); + $blade->directive('inertiaHead', [Directive::class, 'compileHead']); + }); + } + + /** + * Register Artisan commands for managing Inertia middleware creation + * and server-side rendering operations when running in console mode. + */ + protected function registerConsoleCommands(): void + { + if (! $this->app->runningInConsole()) { + return; + } + + $this->commands([ + Commands\CreateMiddleware::class, + Commands\StartSsr::class, + Commands\StopSsr::class, + Commands\CheckSsr::class, + ]); + } + + /** + * Add a 'preserveFragment' method to redirect responses that signals + * the frontend to preserve the URL fragment across the redirect. + */ + protected function registerRedirectMacro(): void + { + RedirectResponse::macro('preserveFragment', function () { + inertia()->preserveFragment(); + + return $this; + }); + } + + /** + * Add an 'inertia' method to the Request class that returns true + * if the current request is an Inertia request. + */ + protected function registerRequestMacro(): void + { + Request::macro('inertia', function () { + return (bool) $this->header(Header::INERTIA); + }); + } + + /** + * Register the router macro. + */ + protected function registerRouterMacro(): void + { + /* + * @param array $props + */ + Router::macro('inertia', function ($uri, $component, $props = []) { + return $this->match(['GET', 'HEAD'], $uri, '\\' . Controller::class) + ->defaults('component', $component) + ->defaults('props', $props); + }); + } + + /** + * Register the testing macros. + * + * @throws LogicException + */ + protected function registerTestingMacros(): void + { + if (class_exists(TestResponse::class)) { + TestResponse::mixin(new TestResponseMacros); + + return; + } + + throw new LogicException('Could not detect TestResponse class.'); + } + + /** + * Register the middleware aliases. + */ + protected function registerMiddleware(): void + { + $this->app['router']->aliasMiddleware( + 'inertia.encrypt', + EncryptHistoryMiddleware::class + ); + } +} From 88a6a06c18caf383764c28cd113932832ccfbc62 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:51 +0000 Subject: [PATCH 46/83] Inertia helper functions (inertia, inertia_location) --- src/inertia/src/helpers.php | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/inertia/src/helpers.php diff --git a/src/inertia/src/helpers.php b/src/inertia/src/helpers.php new file mode 100644 index 000000000..b9eb4fe04 --- /dev/null +++ b/src/inertia/src/helpers.php @@ -0,0 +1,40 @@ +|Arrayable $props + * @return ($component is null ? ResponseFactory : Response) + */ + function inertia(?string $component = null, array|Arrayable $props = []): ResponseFactory|Response + { + $instance = Inertia::getFacadeRoot(); + + if ($component) { + return $instance->render($component, $props); + } + + return $instance; + } +} + +if (! function_exists('inertia_location')) { + /** + * Inertia location helper. + */ + function inertia_location(string $url): SymfonyResponse + { + $instance = Inertia::getFacadeRoot(); + + return $instance->location($url); + } +} From 44386be59fca959c7a3921a0593c7ab3a4294c17 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:51 +0000 Subject: [PATCH 47/83] Inertia config with SSR timeouts, backoff, and error handling --- src/inertia/config/inertia.php | 157 +++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/inertia/config/inertia.php diff --git a/src/inertia/config/inertia.php b/src/inertia/config/inertia.php new file mode 100644 index 000000000..14b02a6ea --- /dev/null +++ b/src/inertia/config/inertia.php @@ -0,0 +1,157 @@ + [ + 'enabled' => (bool) env('INERTIA_SSR_ENABLED', true), + + 'runtime' => env('INERTIA_SSR_RUNTIME', 'node'), + + 'ensure_runtime_exists' => (bool) env('INERTIA_SSR_ENSURE_RUNTIME_EXISTS', false), + + 'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'), + + 'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true), + + // 'bundle' => base_path('bootstrap/ssr/ssr.mjs'), + + /* + |-------------------------------------------------------------------------- + | SSR Timeouts + |-------------------------------------------------------------------------- + | + | Configure connection and read timeouts for SSR requests. These prevent + | coroutines from hanging indefinitely when the SSR server is unresponsive. + | + */ + + 'connect_timeout' => (int) env('INERTIA_SSR_CONNECT_TIMEOUT', 2), + + 'timeout' => (int) env('INERTIA_SSR_TIMEOUT', 5), + + /* + |-------------------------------------------------------------------------- + | SSR Backoff + |-------------------------------------------------------------------------- + | + | When SSR fails, the worker will skip SSR for this many seconds before + | retrying. This acts as a circuit breaker to prevent flooding a dead + | SSR server with requests from every coroutine. + | + */ + + 'backoff' => (float) env('INERTIA_SSR_BACKOFF', 5.0), + + /* + |-------------------------------------------------------------------------- + | SSR Error Handling + |-------------------------------------------------------------------------- + | + | When SSR rendering fails, Inertia gracefully falls back to client-side + | rendering. Set throw_on_error to true to throw an exception instead. + | This is useful for E2E testing where you want SSR errors to fail loudly. + | + | You can also listen for the Hypervel\Inertia\Ssr\SsrRenderFailed event + | to handle failures in your own way (e.g., logging, error tracking). + | + */ + + 'throw_on_error' => (bool) env('INERTIA_SSR_THROW_ON_ERROR', false), + ], + + /* + |-------------------------------------------------------------------------- + | Pages + |-------------------------------------------------------------------------- + | + | Set `ensure_pages_exist` to true if you want to enforce that Inertia page + | components exist on disk when rendering a page. This is useful for + | catching missing or misnamed components. + | + | The `paths` and `extensions` options define where to look for page + | components and which file extensions to consider. + | + */ + + 'pages' => [ + 'ensure_pages_exist' => false, + + 'paths' => [ + resource_path('js/pages'), + ], + + 'extensions' => [ + 'js', + 'jsx', + 'svelte', + 'ts', + 'tsx', + 'vue', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Testing + |-------------------------------------------------------------------------- + | + | When using `assertInertia`, the assertion attempts to locate the + | component as a file relative to the `pages.paths` AND with any of + | the `pages.extensions` specified above. + | + | You can disable this behavior by setting `ensure_pages_exist` + | to false. + | + */ + + 'testing' => [ + 'ensure_pages_exist' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Expose Shared Prop Keys + |-------------------------------------------------------------------------- + | + | When enabled, each page response includes a `sharedProps` metadata key + | listing the top-level prop keys that were registered via `Inertia::share`. + | The frontend can use this to carry shared props over during instant visits. + | + */ + + 'expose_shared_prop_keys' => true, + + /* + |-------------------------------------------------------------------------- + | History + |-------------------------------------------------------------------------- + | + | Enable `encrypt` to encrypt page data before it is stored in the + | browser's history state, preventing sensitive information from + | being accessible after logout. Can also be enabled per-request + | or via the `inertia.encrypt` middleware. + | + */ + + 'history' => [ + 'encrypt' => (bool) env('INERTIA_ENCRYPT_HISTORY', false), + ], +]; From b7c8c644ac19d84db47df2dfcf1af9c71acca499 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:02:51 +0000 Subject: [PATCH 48/83] Middleware generator stub for Inertia --- src/inertia/stubs/middleware.stub | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/inertia/stubs/middleware.stub diff --git a/src/inertia/stubs/middleware.stub b/src/inertia/stubs/middleware.stub new file mode 100644 index 000000000..dae7c729d --- /dev/null +++ b/src/inertia/stubs/middleware.stub @@ -0,0 +1,42 @@ + + */ + public function share(Request $request): array + { + return [ + ...parent::share($request), + // + ]; + } +} From f984be81e1b2e4e9a8a2aa134805c4146cb27284 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:09 +0000 Subject: [PATCH 49/83] Register Inertia flushState calls in AfterEachTestSubscriber --- tests/AfterEachTestSubscriber.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/AfterEachTestSubscriber.php b/tests/AfterEachTestSubscriber.php index 2e880813f..aafe7c70a 100644 --- a/tests/AfterEachTestSubscriber.php +++ b/tests/AfterEachTestSubscriber.php @@ -103,6 +103,10 @@ public function notify(Finished $event): void \Hypervel\Http\Middleware\TrustProxies::flushState(); \Hypervel\Http\Resources\Json\JsonResource::flushState(); \Hypervel\Http\Resources\JsonApi\JsonApiResource::flushState(); + \Hypervel\Inertia\Middleware::flushState(); + \Hypervel\Inertia\ResponseFactory::flushState(); + \Hypervel\Inertia\Ssr\BundleDetector::flushState(); + \Hypervel\Inertia\Ssr\HttpGateway::flushState(); \Hypervel\Log\Context\Repository::flushState(); \Hypervel\Mail\Attachment::flushState(); \Hypervel\Mail\Mailer::flushState(); From ce1b9c620e753624147e41f42acf5f181db77c0a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:17 +0000 Subject: [PATCH 50/83] Inertia test fixtures (views, stubs, enums, middleware) --- .../Fixtures/CustomUrlResolverMiddleware.php | 24 ++++++++ .../Inertia/Fixtures/Enums/IntBackedEnum.php | 10 +++ .../Fixtures/Enums/StringBackedEnum.php | 10 +++ tests/Inertia/Fixtures/Enums/UnitEnum.php | 10 +++ tests/Inertia/Fixtures/Example/Page.vue | 3 + .../Fixtures/ExampleInertiaPropsProvider.php | 27 ++++++++ tests/Inertia/Fixtures/ExampleMiddleware.php | 61 +++++++++++++++++++ tests/Inertia/Fixtures/ExamplePage.vue | 3 + tests/Inertia/Fixtures/FakeGateway.php | 34 +++++++++++ tests/Inertia/Fixtures/FakeResource.php | 42 +++++++++++++ .../Fixtures/HttpExceptionMiddleware.php | 18 ++++++ .../Inertia/Fixtures/MergeWithSharedProp.php | 24 ++++++++ .../Inertia/Fixtures/SsrExceptMiddleware.php | 20 ++++++ tests/Inertia/Fixtures/User.php | 12 ++++ tests/Inertia/Fixtures/UserResource.php | 11 ++++ .../Fixtures/WithAllErrorsMiddleware.php | 10 +++ tests/Inertia/Fixtures/app.blade.php | 1 + tests/Inertia/Fixtures/ssr-bundle.js | 1 + tests/Inertia/Fixtures/welcome.blade.php | 5 ++ 19 files changed, 326 insertions(+) create mode 100644 tests/Inertia/Fixtures/CustomUrlResolverMiddleware.php create mode 100644 tests/Inertia/Fixtures/Enums/IntBackedEnum.php create mode 100644 tests/Inertia/Fixtures/Enums/StringBackedEnum.php create mode 100644 tests/Inertia/Fixtures/Enums/UnitEnum.php create mode 100644 tests/Inertia/Fixtures/Example/Page.vue create mode 100644 tests/Inertia/Fixtures/ExampleInertiaPropsProvider.php create mode 100644 tests/Inertia/Fixtures/ExampleMiddleware.php create mode 100644 tests/Inertia/Fixtures/ExamplePage.vue create mode 100644 tests/Inertia/Fixtures/FakeGateway.php create mode 100644 tests/Inertia/Fixtures/FakeResource.php create mode 100644 tests/Inertia/Fixtures/HttpExceptionMiddleware.php create mode 100644 tests/Inertia/Fixtures/MergeWithSharedProp.php create mode 100644 tests/Inertia/Fixtures/SsrExceptMiddleware.php create mode 100644 tests/Inertia/Fixtures/User.php create mode 100644 tests/Inertia/Fixtures/UserResource.php create mode 100644 tests/Inertia/Fixtures/WithAllErrorsMiddleware.php create mode 100644 tests/Inertia/Fixtures/app.blade.php create mode 100644 tests/Inertia/Fixtures/ssr-bundle.js create mode 100644 tests/Inertia/Fixtures/welcome.blade.php diff --git a/tests/Inertia/Fixtures/CustomUrlResolverMiddleware.php b/tests/Inertia/Fixtures/CustomUrlResolverMiddleware.php new file mode 100644 index 000000000..b272a6d76 --- /dev/null +++ b/tests/Inertia/Fixtures/CustomUrlResolverMiddleware.php @@ -0,0 +1,24 @@ + +
This is an example Inertia page component.
+ diff --git a/tests/Inertia/Fixtures/ExampleInertiaPropsProvider.php b/tests/Inertia/Fixtures/ExampleInertiaPropsProvider.php new file mode 100644 index 000000000..3cc75d625 --- /dev/null +++ b/tests/Inertia/Fixtures/ExampleInertiaPropsProvider.php @@ -0,0 +1,27 @@ + $properties + */ + public function __construct( + protected array $properties + ) { + } + + /** + * @return iterable + */ + public function toInertiaProperties(RenderContext $context): iterable + { + return $this->properties; + } +} diff --git a/tests/Inertia/Fixtures/ExampleMiddleware.php b/tests/Inertia/Fixtures/ExampleMiddleware.php new file mode 100644 index 000000000..b293f9a73 --- /dev/null +++ b/tests/Inertia/Fixtures/ExampleMiddleware.php @@ -0,0 +1,61 @@ + $shared + */ + public function __construct(mixed $version = null, array $shared = []) + { + $this->version = $version; + $this->shared = $shared; + } + + /** + * Determines the current asset version. + * + * @see https://inertiajs.com/asset-versioning + */ + public function version(Request $request): ?string + { + return $this->version !== null ? (string) $this->version : null; + } + + /** + * Defines the props that are shared by default. + * + * @see https://inertiajs.com/shared-data + */ + public function share(Request $request): array + { + return array_merge(parent::share($request), $this->shared); + } + + /** + * Determines what to do when an Inertia action returned with no response. + * By default, we'll redirect the user back to where they came from. + */ + public function onEmptyResponse(Request $request, Response $response): Response + { + throw new LogicException('An empty Inertia response was returned.'); + } +} diff --git a/tests/Inertia/Fixtures/ExamplePage.vue b/tests/Inertia/Fixtures/ExamplePage.vue new file mode 100644 index 000000000..274922719 --- /dev/null +++ b/tests/Inertia/Fixtures/ExamplePage.vue @@ -0,0 +1,3 @@ + diff --git a/tests/Inertia/Fixtures/FakeGateway.php b/tests/Inertia/Fixtures/FakeGateway.php new file mode 100644 index 000000000..306aba044 --- /dev/null +++ b/tests/Inertia/Fixtures/FakeGateway.php @@ -0,0 +1,34 @@ +times; + + if (! Config::get('inertia.ssr.enabled', false)) { + return null; + } + + return new Response( + "\nExample SSR Title\n", + '

This is some example SSR content

' + ); + } +} diff --git a/tests/Inertia/Fixtures/FakeResource.php b/tests/Inertia/Fixtures/FakeResource.php new file mode 100644 index 000000000..ce86e5c1a --- /dev/null +++ b/tests/Inertia/Fixtures/FakeResource.php @@ -0,0 +1,42 @@ + + */ + private array $data; + + /** + * The "data" wrapper that should be applied. + */ + public static ?string $wrap = null; + + /** + * @param array $resource + */ + public function __construct(array $resource) + { + parent::__construct(null); + $this->data = $resource; + } + + /** + * Transform the resource into an array. + * + * @return array + */ + public function toArray(Request $request): array + { + return $this->data; + } +} diff --git a/tests/Inertia/Fixtures/HttpExceptionMiddleware.php b/tests/Inertia/Fixtures/HttpExceptionMiddleware.php new file mode 100644 index 000000000..bdb9b5d03 --- /dev/null +++ b/tests/Inertia/Fixtures/HttpExceptionMiddleware.php @@ -0,0 +1,18 @@ + 'My App', + ]); + } +} diff --git a/tests/Inertia/Fixtures/MergeWithSharedProp.php b/tests/Inertia/Fixtures/MergeWithSharedProp.php new file mode 100644 index 000000000..ad60c4aef --- /dev/null +++ b/tests/Inertia/Fixtures/MergeWithSharedProp.php @@ -0,0 +1,24 @@ + $items + */ + public function __construct(protected array $items = []) + { + } + + public function toInertiaProperty(PropertyContext $prop): mixed + { + return array_merge(Inertia::getShared($prop->key, []), $this->items); + } +} diff --git a/tests/Inertia/Fixtures/SsrExceptMiddleware.php b/tests/Inertia/Fixtures/SsrExceptMiddleware.php new file mode 100644 index 000000000..8cdc6b606 --- /dev/null +++ b/tests/Inertia/Fixtures/SsrExceptMiddleware.php @@ -0,0 +1,20 @@ + + */ + protected array $withoutSsr = [ + 'admin/*', + 'nova/*', + ]; +} diff --git a/tests/Inertia/Fixtures/User.php b/tests/Inertia/Fixtures/User.php new file mode 100644 index 000000000..9da8cfc1a --- /dev/null +++ b/tests/Inertia/Fixtures/User.php @@ -0,0 +1,12 @@ + + +Welcome +Welcome + From 6f526f0c7b7f0971c5350eeacdb4df45c523923b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:28 +0000 Subject: [PATCH 51/83] Inertia test base class and InteractsWithUserModels trait --- tests/Inertia/InteractsWithUserModels.php | 25 +++++++++ tests/Inertia/TestCase.php | 68 +++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 tests/Inertia/InteractsWithUserModels.php create mode 100644 tests/Inertia/TestCase.php diff --git a/tests/Inertia/InteractsWithUserModels.php b/tests/Inertia/InteractsWithUserModels.php new file mode 100644 index 000000000..bce9bc0d8 --- /dev/null +++ b/tests/Inertia/InteractsWithUserModels.php @@ -0,0 +1,25 @@ +set('database.default', 'sqlite'); + config()->set('database.connections.sqlite.database', ':memory:'); + + DB::statement('DROP TABLE IF EXISTS users'); + DB::statement('CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT)'); + DB::table('users')->insert(array_fill(0, 40, ['id' => null])); + } + + protected function tearDownInteractsWithUserModels(): void + { + DB::statement('DROP TABLE users'); + } +} diff --git a/tests/Inertia/TestCase.php b/tests/Inertia/TestCase.php new file mode 100644 index 000000000..1bad6c5a6 --- /dev/null +++ b/tests/Inertia/TestCase.php @@ -0,0 +1,68 @@ + + */ + protected const EXAMPLE_PAGE_OBJECT = [ + 'component' => 'Foo/Bar', + 'props' => ['foo' => 'bar'], + 'url' => '/test', + 'version' => '', + ]; + + protected function getPackageProviders(ApplicationContract $app): array + { + return [ + InertiaServiceProvider::class, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + View::addLocation(__DIR__ . '/Fixtures'); + + Inertia::setRootView('welcome'); + Inertia::transformComponentUsing(); + config()->set('inertia.testing.ensure_pages_exist', false); + config()->set('inertia.pages.paths', [realpath(__DIR__)]); + } + + /** + * Make a mock request through the given middleware. + * + * @param array|class-string $middleware + * @return TestResponse + */ + protected function makeMockRequest(mixed $view, string|array $middleware = []): TestResponse + { + $middleware = is_array($middleware) ? $middleware : [$middleware]; + + app('router')->middleware($middleware)->get('/example-url', function () use ($view) { + return is_callable($view) ? $view() : $view; + }); + + return $this->get('/example-url'); + } +} From 3a6c2267f41e99609708baba07d7a7356c2c76cb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:40 +0000 Subject: [PATCH 52/83] Port AlwaysPropTest --- tests/Inertia/AlwaysPropTest.php | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/Inertia/AlwaysPropTest.php diff --git a/tests/Inertia/AlwaysPropTest.php b/tests/Inertia/AlwaysPropTest.php new file mode 100644 index 000000000..25c35fb1c --- /dev/null +++ b/tests/Inertia/AlwaysPropTest.php @@ -0,0 +1,61 @@ +assertSame('An always value', $alwaysProp()); + } + + public function testCanAcceptScalarValues(): void + { + $alwaysProp = new AlwaysProp('An always value'); + + $this->assertSame('An always value', $alwaysProp()); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $alwaysProp = new AlwaysProp('date'); + + $this->assertSame('date', $alwaysProp()); + } + + public function testCanAcceptCallables(): void + { + $callable = new class { + public function __invoke(): string + { + return 'An always value'; + } + }; + + $alwaysProp = new AlwaysProp($callable); + + $this->assertSame('An always value', $alwaysProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $alwaysProp = new AlwaysProp(function (Request $request) { + return $request; + }); + + $this->assertInstanceOf(Request::class, $alwaysProp()); + } +} From 84fc7d19a0762cdc2b2e0542242872ef5ad483d9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:40 +0000 Subject: [PATCH 53/83] Port ComponentTest with Context-based SSR state --- tests/Inertia/ComponentTest.php | 215 ++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 tests/Inertia/ComponentTest.php diff --git a/tests/Inertia/ComponentTest.php b/tests/Inertia/ComponentTest.php new file mode 100644 index 000000000..fecafccb2 --- /dev/null +++ b/tests/Inertia/ComponentTest.php @@ -0,0 +1,215 @@ +app->bind(Gateway::class, FakeGateway::class); + } + + /** + * @param array $data + */ + protected function renderView(string $contents, array $data = []): string + { + $state = CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); + $state->page = $data['page'] ?? []; + + return Blade::render($contents, $data, true); + } + + /** + * Reset InertiaState between renders within the same test. + */ + protected function resetInertiaState(): void + { + CoroutineContext::forget(InertiaState::CONTEXT_KEY); + } + + public function testHeadComponentRendersFallbackSlotWhenSsrIsDisabled() + { + Config::set(['inertia.ssr.enabled' => false]); + + $view = 'Fallback Title'; + + $this->assertStringContainsString( + 'Fallback Title', + $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + } + + public function testHeadComponentRendersSsrHeadWhenSsrIsEnabled() + { + Config::set(['inertia.ssr.enabled' => true]); + + $view = 'Fallback Title'; + $rendered = $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertStringContainsString('Example SSR Title', $rendered); + $this->assertStringNotContainsString('Fallback Title', $rendered); + } + + public function testAppComponentRendersClientSideDivWhenSsrIsDisabled() + { + Config::set(['inertia.ssr.enabled' => false]); + + $view = ''; + $rendered = $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertStringContainsString('
', $rendered); + $this->assertStringContainsString('data-page="app"', $rendered); + } + + public function testAppComponentRendersSsrBodyWhenSsrIsEnabled() + { + Config::set(['inertia.ssr.enabled' => true]); + + $view = ''; + + $this->assertSame( + '

This is some example SSR content

', + trim($this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT])) + ); + } + + public function testAppComponentAcceptsCustomId() + { + Config::set(['inertia.ssr.enabled' => false]); + + $view = ''; + $rendered = $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertStringContainsString('
', $rendered); + $this->assertStringContainsString('data-page="custom"', $rendered); + } + + public function testSsrIsOnlyDispatchedOnceWithComponents() + { + Config::set(['inertia.ssr.enabled' => true]); + $this->app->instance(Gateway::class, $gateway = new FakeGateway); + + $view = 'Fallback'; + $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertSame(1, $gateway->times); + } + + public function testAppComponentMatchesDirectiveOutputWhenSsrIsDisabled() + { + Config::set(['inertia.ssr.enabled' => false]); + + $directive = $this->renderView('@inertia', ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->resetInertiaState(); + + $component = trim($this->renderView('', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->assertSame($directive, $component); + } + + public function testAppComponentMatchesDirectiveOutputWhenSsrIsEnabled() + { + Config::set(['inertia.ssr.enabled' => true]); + + $directive = $this->renderView('@inertia', ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->resetInertiaState(); + + $component = trim($this->renderView('', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->assertSame($directive, $component); + } + + public function testAppComponentWithCustomIdMatchesDirectiveOutput() + { + Config::set(['inertia.ssr.enabled' => false]); + + $directive = $this->renderView('@inertia("foo")', ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->resetInertiaState(); + + $component = trim($this->renderView('', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->assertSame($directive, $component); + } + + public function testHeadComponentWithoutSlotMatchesDirectiveOutputWhenSsrIsDisabled() + { + Config::set(['inertia.ssr.enabled' => false]); + + $directive = $this->renderView('@inertiaHead', ['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->resetInertiaState(); + + $component = trim($this->renderView('', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->assertSame($directive, $component); + } + + public function testHeadComponentWithoutSlotMatchesDirectiveOutputWhenSsrIsEnabled() + { + Config::set(['inertia.ssr.enabled' => true]); + + $directive = trim($this->renderView('@inertiaHead', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->resetInertiaState(); + + $component = trim($this->renderView('', ['page' => self::EXAMPLE_PAGE_OBJECT])); + + $this->assertSame($directive, $component); + } + + public function testComponentsDoNotCreateCachedViewFilesPerRequest() + { + Config::set(['inertia.ssr.enabled' => true]); + + $viewCachePath = $this->app['config']['view.compiled']; + $view = 'Fallback'; + + $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]); + $cachedViews = glob($viewCachePath . '/*.php'); + + $this->resetInertiaState(); + + $this->renderView($view, ['page' => ['component' => 'Different', 'props' => ['foo' => 'bar']]]); + $this->assertSame($cachedViews, glob($viewCachePath . '/*.php')); + } + + public function testInertiaStateDoesNotLeakBetweenRequests() + { + Config::set(['inertia.ssr.enabled' => true]); + + $state1 = CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); + $state1->page = self::EXAMPLE_PAGE_OBJECT; + $state1->ssrDispatched = true; + $state1->ssrResponse = app(Gateway::class)->dispatch($state1->page); + + $this->assertNotNull($state1->ssrResponse); + + // Simulate request boundary by clearing Context state + $this->resetInertiaState(); + + $state2 = CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); + + $this->assertNotSame($state1, $state2); + $this->assertNull($state2->ssrResponse); + $this->assertFalse($state2->ssrDispatched); + } +} From cdf2294160731392c5d2e7210d67c3aeaeafb1f1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:40 +0000 Subject: [PATCH 54/83] Port ControllerTest --- tests/Inertia/ControllerTest.php | 40 ++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/Inertia/ControllerTest.php diff --git a/tests/Inertia/ControllerTest.php b/tests/Inertia/ControllerTest.php new file mode 100644 index 000000000..77d3c2844 --- /dev/null +++ b/tests/Inertia/ControllerTest.php @@ -0,0 +1,40 @@ +get('/', Controller::class) + ->defaults('component', 'User/Edit') + ->defaults('props', [ + 'user' => ['name' => 'Jonathan'], + ]); + + $response = $this->get('/'); + + $this->assertEquals($response->viewData('page'), [ + 'component' => 'User/Edit', + 'props' => [ + 'user' => ['name' => 'Jonathan'], + 'errors' => (object) [], + ], + 'url' => '/', + 'version' => '', + 'sharedProps' => ['errors'], + ]); + } +} From 4190238b95446764fda25c12f6f022d822d1c06f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:40 +0000 Subject: [PATCH 55/83] Port DeepMergePropTest --- tests/Inertia/DeepMergePropTest.php | 50 +++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/Inertia/DeepMergePropTest.php diff --git a/tests/Inertia/DeepMergePropTest.php b/tests/Inertia/DeepMergePropTest.php new file mode 100644 index 000000000..4bbbc05b1 --- /dev/null +++ b/tests/Inertia/DeepMergePropTest.php @@ -0,0 +1,50 @@ + 'A merge prop value'))->deepMerge(); + + $this->assertSame('A merge prop value', $mergeProp()); + } + + public function testCanInvokeWithANonCallback(): void + { + $mergeProp = (new MergeProp(['key' => 'value']))->deepMerge(); + + $this->assertSame(['key' => 'value'], $mergeProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $mergeProp = (new MergeProp(fn (Request $request) => $request))->deepMerge(); + + $this->assertInstanceOf(Request::class, $mergeProp()); + } + + public function testCanUseSingleStringAsKeyToMatchOn(): void + { + $mergeProp = (new MergeProp(['key' => 'value']))->matchOn('key'); + + $this->assertEquals(['key'], $mergeProp->matchesOn()); + } + + public function testCanUseAnArrayOfStringsAsKeysToMatchOn(): void + { + $mergeProp = (new MergeProp(['key' => 'value']))->matchOn(['key', 'anotherKey']); + + $this->assertEquals(['key', 'anotherKey'], $mergeProp->matchesOn()); + } +} From 3a3a91997f3f6cae8ce71149c905af54c0cc2e64 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:40 +0000 Subject: [PATCH 56/83] Port DeferPropTest --- tests/Inertia/DeferPropTest.php | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 tests/Inertia/DeferPropTest.php diff --git a/tests/Inertia/DeferPropTest.php b/tests/Inertia/DeferPropTest.php new file mode 100644 index 000000000..98188aa08 --- /dev/null +++ b/tests/Inertia/DeferPropTest.php @@ -0,0 +1,60 @@ +assertSame('A deferred value', $deferProp()); + $this->assertSame('default', $deferProp->group()); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $deferProp = new DeferProp('date'); + + $this->assertSame('date', $deferProp()); + } + + public function testCanInvokeAndMerge(): void + { + $deferProp = (new DeferProp(function () { + return 'A deferred value'; + }))->merge(); + + $this->assertSame('A deferred value', $deferProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $deferProp = new DeferProp(function (Request $request) { + return $request; + }); + + $this->assertInstanceOf(Request::class, $deferProp()); + } + + public function testIsOnceable(): void + { + $deferProp = (new DeferProp(fn () => 'value')) + ->once(as: 'custom-key', until: 60); + + $this->assertTrue($deferProp->shouldResolveOnce()); + $this->assertSame('custom-key', $deferProp->getKey()); + $this->assertNotNull($deferProp->expiresAt()); + } +} From 758b7b76101af91b5feeb8d75cdf92467d517ef3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:55 +0000 Subject: [PATCH 57/83] Port DirectiveTest --- tests/Inertia/DirectiveTest.php | 113 ++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/Inertia/DirectiveTest.php diff --git a/tests/Inertia/DirectiveTest.php b/tests/Inertia/DirectiveTest.php new file mode 100644 index 000000000..2da5760cd --- /dev/null +++ b/tests/Inertia/DirectiveTest.php @@ -0,0 +1,113 @@ +app->bind(Gateway::class, FakeGateway::class); + $this->filesystem = m::mock(Filesystem::class); + + /** @var Filesystem $filesystem */ + $filesystem = $this->filesystem; + $this->compiler = new BladeCompiler($filesystem, __DIR__ . '/cache/views'); + $this->compiler->directive('inertia', [Directive::class, 'compile']); + $this->compiler->directive('inertiaHead', [Directive::class, 'compileHead']); + } + + /** + * @param array $data + */ + protected function renderView(string $contents, array $data = []): string + { + return Blade::render($contents, $data, true); + } + + public function testInertiaDirectiveRendersTheRootElement(): void + { + Config::set(['inertia.ssr.enabled' => false]); + + $html = '
'; + + $this->assertSame($html, $this->renderView('@inertia', ['page' => self::EXAMPLE_PAGE_OBJECT])); + $this->assertSame($html, $this->renderView('@inertia()', ['page' => self::EXAMPLE_PAGE_OBJECT])); + $this->assertSame($html, $this->renderView('@inertia("")', ['page' => self::EXAMPLE_PAGE_OBJECT])); + $this->assertSame($html, $this->renderView("@inertia('')", ['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testInertiaDirectiveRendersServerSideRenderedContentWhenEnabled(): void + { + Config::set(['inertia.ssr.enabled' => true]); + + $this->assertSame( + '

This is some example SSR content

', + $this->renderView('@inertia', ['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + } + + public function testInertiaDirectiveCanUseADifferentRootElementId(): void + { + Config::set(['inertia.ssr.enabled' => false]); + + $html = '
'; + + $this->assertSame($html, $this->renderView('@inertia(foo)', ['page' => self::EXAMPLE_PAGE_OBJECT])); + $this->assertSame($html, $this->renderView("@inertia('foo')", ['page' => self::EXAMPLE_PAGE_OBJECT])); + $this->assertSame($html, $this->renderView('@inertia("foo")', ['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testInertiaHeadDirectiveRendersNothing(): void + { + Config::set(['inertia.ssr.enabled' => false]); + + $this->assertEmpty($this->renderView('@inertiaHead', ['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testInertiaHeadDirectiveRendersServerSideRenderedHeadElementsWhenEnabled(): void + { + Config::set(['inertia.ssr.enabled' => true]); + + $this->assertSame( + "\nExample SSR Title\n", + $this->renderView('@inertiaHead', ['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + } + + public function testTheServerSideRenderingRequestIsDispatchedOnlyOncePerRequest(): void + { + Config::set(['inertia.ssr.enabled' => true]); + $this->app->instance(Gateway::class, $gateway = new FakeGateway); + + $view = "\n\n\n@inertiaHead\n\n\n@inertia\n\n"; + $expected = "\n\n\n\nExample SSR Title\n\n\n

This is some example SSR content

\n"; + + $this->assertSame( + $expected, + $this->renderView($view, ['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + + $this->assertSame(1, $gateway->times); + } +} From b0128d35259708a51f894e9daec78cb33693c0c6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:55 +0000 Subject: [PATCH 58/83] Port ExceptionResponseTest --- tests/Inertia/ExceptionResponseTest.php | 197 ++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/Inertia/ExceptionResponseTest.php diff --git a/tests/Inertia/ExceptionResponseTest.php b/tests/Inertia/ExceptionResponseTest.php new file mode 100644 index 000000000..b998aab27 --- /dev/null +++ b/tests/Inertia/ExceptionResponseTest.php @@ -0,0 +1,197 @@ +get('/', function () { + abort(500); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertStatus(500); + $this->assertFalse($response->headers->has('X-Inertia')); + } + + public function testExceptionsCanRenderInertiaPages(): void + { + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', [ + 'status' => $response->statusCode(), + 'message' => $response->exception->getMessage(), + ]); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/', function () { + abort(500, 'Something went wrong'); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertStatus(500); + $response->assertJson([ + 'component' => 'Error', + 'props' => [ + 'status' => 500, + 'message' => 'Something went wrong', + ], + ]); + } + + public function testExceptionsCanReturnRedirects(): void + { + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + if ($response->statusCode() === 419) { + return redirect()->to('/login'); + } + + return $response->render('Error', ['status' => $response->statusCode()]); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/', function () { + abort(419); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertRedirect('/login'); + } + + public function testExceptionsCanFallThroughToDefault(): void + { + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + if ($response->statusCode() === 500) { + return null; + } + + return $response->render('Error', ['status' => $response->statusCode()]); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/', function () { + abort(500); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertStatus(500); + $this->assertFalse($response->headers->has('X-Inertia')); + } + + public function testExceptionsWithSharedData(): void + { + $kernel = $this->app->make(Kernel::class); + $kernel->appendMiddlewareToGroup('web', HttpExceptionMiddleware::class); + + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', ['status' => $response->statusCode()]) + ->withSharedData(); + }); + + $response = $this->get('/non-existent-page', ['X-Inertia' => 'true']); + + $response->assertStatus(404); + $response->assertJson([ + 'component' => 'Error', + 'props' => [ + 'status' => 404, + 'appName' => 'My App', + ], + ]); + } + + public function testExceptionsWithSharedDataFromExplicitMiddleware(): void + { + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', ['status' => $response->statusCode()]) + ->usingMiddleware(HttpExceptionMiddleware::class) + ->withSharedData(); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/', function () { + abort(500); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertStatus(500); + $response->assertJson([ + 'component' => 'Error', + 'props' => [ + 'status' => 500, + 'appName' => 'My App', + ], + ]); + } + + public function testExceptionsWithoutSharedData(): void + { + $kernel = $this->app->make(Kernel::class); + $kernel->appendMiddlewareToGroup('web', HttpExceptionMiddleware::class); + + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', ['status' => $response->statusCode()]); + }); + + $response = $this->get('/non-existent-page', ['X-Inertia' => 'true']); + + $response->assertStatus(404); + $page = $response->json(); + $this->assertSame('Error', $page['component']); + $this->assertSame(404, $page['props']['status']); + $this->assertArrayNotHasKey('appName', $page['props']); + } + + public function testExceptionsWithCustomRootView(): void + { + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', ['status' => $response->statusCode()]) + ->rootView('custom'); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/', function () { + abort(500); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertStatus(500); + $response->assertJson(['component' => 'Error']); + } + + public function testExceptionsOutsideMiddlewareAreHandled(): void + { + $kernel = $this->app->make(Kernel::class); + $kernel->appendMiddlewareToGroup('web', Middleware::class); + + Inertia::handleExceptionsUsing(function (ExceptionResponse $response) { + return $response->render('Error', ['status' => $response->statusCode()]); + }); + + $response = $this->get('/non-existent-page', ['X-Inertia' => 'true']); + + $response->assertStatus(404); + $response->assertJson([ + 'component' => 'Error', + 'props' => [ + 'status' => 404, + ], + ]); + } +} From 94597a6bc1a6478360d9beb23a2d0d2a28eb190b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:55 +0000 Subject: [PATCH 59/83] Port HelperTest --- tests/Inertia/HelperTest.php | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 tests/Inertia/HelperTest.php diff --git a/tests/Inertia/HelperTest.php b/tests/Inertia/HelperTest.php new file mode 100644 index 000000000..5ffcf03cc --- /dev/null +++ b/tests/Inertia/HelperTest.php @@ -0,0 +1,32 @@ +assertInstanceOf(ResponseFactory::class, inertia()); + } + + public function testTheHelperFunctionReturnsAResponseInstance(): void + { + $this->assertInstanceOf(Response::class, inertia('User/Edit', ['user' => ['name' => 'Jonathan']])); + } + + public function testTheInstanceIsTheSameAsTheFacadeInstance(): void + { + Inertia::share('key', 'value'); + $this->assertEquals('value', inertia()->getShared('key')); + } +} From 915ac26bda5e3a31d942691731ffb8bb3d513a45 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:55 +0000 Subject: [PATCH 60/83] Port HistoryTest --- tests/Inertia/HistoryTest.php | 227 ++++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 tests/Inertia/HistoryTest.php diff --git a/tests/Inertia/HistoryTest.php b/tests/Inertia/HistoryTest.php new file mode 100644 index 000000000..c3b41aa82 --- /dev/null +++ b/tests/Inertia/HistoryTest.php @@ -0,0 +1,227 @@ +get('/', function () { + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJsonMissing(['encryptHistory' => true]); + $response->assertJsonMissing(['clearHistory' => true]); + } + + public function testTheHistoryCanBeEncrypted(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::encryptHistory(); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'encryptHistory' => true, + ]); + } + + public function testTheHistoryCanBeEncryptedViaMiddleware(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class, EncryptHistoryMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'encryptHistory' => true, + ]); + } + + public function testTheHistoryCanBeEncryptedViaMiddlewareAlias(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class, 'inertia.encrypt'])->get('/', function () { + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'encryptHistory' => true, + ]); + } + + public function testTheHistoryCanBeEncryptedGlobally(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Config::set('inertia.history.encrypt', true); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'encryptHistory' => true, + ]); + } + + public function testTheHistoryCanBeEncryptedGloballyAndOverridden(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Config::set('inertia.history.encrypt', true); + + Inertia::encryptHistory(false); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJsonMissing(['encryptHistory' => true]); + } + + public function testTheHistoryCanBeCleared(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::clearHistory(); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'clearHistory' => true, + ]); + } + + public function testTheHistoryCanBeClearedWhenRedirecting(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::clearHistory(); + + return redirect('/users'); + }); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/users', function () { + return Inertia::render('User/Edit'); + }); + + $this->followingRedirects(); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertContent('
'); + } + + public function testTheFragmentIsNotPreservedByDefault(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJsonMissing([ + 'preserveFragment' => true, + ]); + } + + public function testTheFragmentCanBePreservedViaInertiaFacade(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::preserveFragment(); + + return redirect('/users'); + }); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/users', function () { + return Inertia::render('User/Edit'); + }); + + $this->withoutExceptionHandling()->get('/'); + + $response = $this->withoutExceptionHandling()->get('/users', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'preserveFragment' => true, + ]); + } + + public function testTheFragmentCanBePreservedViaRedirectMacro(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return redirect('/users')->preserveFragment(); /* @phpstan-ignore method.notFound */ + }); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/users', function () { + return Inertia::render('User/Edit'); + }); + + $this->withoutExceptionHandling()->get('/'); + + $response = $this->withoutExceptionHandling()->get('/users', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'preserveFragment' => true, + ]); + } +} From 17d23f43fb2bede850e75941676acf815a33d252 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:03:55 +0000 Subject: [PATCH 61/83] Port HttpGatewayTest with circuit breaker and scalar JSON regression tests --- tests/Inertia/HttpGatewayTest.php | 578 ++++++++++++++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 tests/Inertia/HttpGatewayTest.php diff --git a/tests/Inertia/HttpGatewayTest.php b/tests/Inertia/HttpGatewayTest.php new file mode 100644 index 000000000..db356bcca --- /dev/null +++ b/tests/Inertia/HttpGatewayTest.php @@ -0,0 +1,578 @@ +gateway = app(HttpGateway::class); + $this->renderUrl = $this->gateway->getProductionUrl('/render'); + + Http::preventStrayRequests(); + } + + protected function tearDown(): void + { + $this->removeHotFile(); + + parent::tearDown(); + } + + protected function createHotFile(string $url = 'http://localhost:5173'): void + { + file_put_contents(public_path('hot'), $url); + } + + protected function removeHotFile(): void + { + $hotFile = public_path('hot'); + if (file_exists($hotFile)) { + unlink($hotFile); + } + } + + public function testItReturnsNullWhenSsrIsDisabled(): void + { + config([ + 'inertia.ssr.enabled' => false, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItReturnsNullWhenNoBundleFileIsDetected(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => null, + ]); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItUsesTheConfiguredHttpUrlWhenTheBundleFileIsDetected(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'head' => ['SSR Test', ''], + 'body' => '
SSR Response
', + ])), + ]); + + $this->assertNotNull( + $response = $this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + + $this->assertEquals("SSR Test\n", $response->head); + $this->assertEquals('
SSR Response
', $response->body); + } + + public function testItUsesTheConfiguredHttpUrlWhenBundleFileDetectionIsDisabled(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.ensure_bundle_exists' => false, + 'inertia.ssr.bundle' => null, + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'head' => ['SSR Test', ''], + 'body' => '
SSR Response
', + ])), + ]); + + $this->assertNotNull( + $response = $this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT]) + ); + + $this->assertEquals("SSR Test\n", $response->head); + $this->assertEquals('
SSR Response
', $response->body); + } + + public function testItReturnsNullWhenTheHttpRequestFails(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response(null, 500), + ]); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItReturnsNullWhenInvalidJsonIsReturned(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response('invalid json'), + ]); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + /** + * Create a new connection exception for use during stubbing. + * + * This is copied over from Laravel's Http::failedConnection() helper + * method, which is only available in Laravel 11.32.0 and later. + */ + private static function rejectionForFailedConnection(): PromiseInterface + { + return Create::rejectionFor( + new ConnectException('Connection refused', new Request('GET', '/')) + ); + } + + public function testHealthCheckTheSsrServer(): void + { + Http::fake([ + $this->gateway->getProductionUrl('/health') => Http::sequence() + ->push(status: 200) + ->push(status: 500) + ->pushResponse(self::rejectionForFailedConnection()), + ]); + + $this->assertTrue($this->gateway->isHealthy()); + $this->assertFalse($this->gateway->isHealthy()); + $this->assertFalse($this->gateway->isHealthy()); + } + + public function testItUsesViteHotUrlWhenRunningHot(): void + { + config(['inertia.ssr.enabled' => true]); + + $this->createHotFile('http://localhost:5173'); + + Http::fake([ + 'http://localhost:5173/__inertia_ssr' => Http::response(json_encode([ + 'head' => ['Hot SSR'], + 'body' => '
Hot Response
', + ])), + ]); + + $response = $this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertNotNull($response); + $this->assertEquals('Hot SSR', $response->head); + $this->assertEquals('
Hot Response
', $response->body); + } + + public function testItUsesViteHotUrlEvenWhenBundleFileExists(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->createHotFile('http://localhost:5173'); + + Http::fake([ + 'http://localhost:5173/__inertia_ssr' => Http::response(json_encode([ + 'head' => ['Hot SSR'], + 'body' => '
Hot Response
', + ])), + $this->renderUrl => Http::response(json_encode([ + 'head' => ['Production SSR'], + 'body' => '
Production Response
', + ])), + ]); + + $response = $this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT]); + + $this->assertNotNull($response); + $this->assertEquals('Hot SSR', $response->head); + $this->assertEquals('
Hot Response
', $response->body); + } + + public function testItReturnsNullWhenPathIsExcludedFromSsr(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->except(['admin/*']); + + $this->get('/admin/dashboard'); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItDispatchesWhenPathIsNotExcludedFromSsr(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'head' => ['SSR Test'], + 'body' => '
SSR Response
', + ])), + ]); + + $this->gateway->except(['admin/*']); + + $this->get('/users'); + + $this->assertNotNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItReturnsNullWhenFullUrlIsExcludedFromSsr(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->except(['http://localhost/admin/*']); + + $this->get('/admin/dashboard'); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testExceptAcceptsString(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->except('admin/*'); + + $this->get('/admin/dashboard'); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testProductionUrlStripsTrailingSlash(): void + { + config(['inertia.ssr.url' => 'http://127.0.0.1:13714/']); + + $gateway = app(HttpGateway::class); + + $this->assertEquals('http://127.0.0.1:13714/render', $gateway->getProductionUrl('/render')); + } + + public function testExceptCanBeCalledMultipleTimes(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->except('admin/*'); + $this->gateway->except(['nova/*', 'filament/*']); + + $this->get('/nova/resources'); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItDispatchesEventWhenSsrFails(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'error' => 'window is not defined', + 'type' => 'browser-api', + 'hint' => 'Wrap in lifecycle hook', + 'browserApi' => 'window', + ]), 500), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + + Event::assertDispatched(SsrRenderFailed::class, function (SsrRenderFailed $event) { + return $event->error === 'window is not defined' + && $event->type === SsrErrorType::BrowserApi + && $event->hint === 'Wrap in lifecycle hook' + && $event->browserApi === 'window' + && $event->component() === 'Foo/Bar'; + }); + } + + public function testItHandlesConnectionErrorsGracefully(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => self::rejectionForFailedConnection(), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + + Event::assertDispatched(SsrRenderFailed::class, function (SsrRenderFailed $event) { + return $event->type === SsrErrorType::Connection + && str_contains($event->error, 'Connection refused'); + }); + } + + public function testItThrowsExceptionWhenThrowOnErrorIsEnabled(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.throw_on_error' => true, + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'error' => 'window is not defined', + 'type' => 'browser-api', + 'hint' => 'Wrap in lifecycle hook', + 'browserApi' => 'window', + 'sourceLocation' => 'resources/js/Pages/Dashboard.vue:10:5', + ]), 500), + ]); + + $this->expectException(SsrException::class); + $this->expectExceptionMessage('SSR render failed for component [Foo/Bar]: window is not defined'); + + $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + } + + public function testSsrExceptionContainsErrorDetails(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.throw_on_error' => true, + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'error' => 'window is not defined', + 'type' => 'browser-api', + 'hint' => 'Wrap in lifecycle hook', + 'browserApi' => 'window', + 'sourceLocation' => 'resources/js/Pages/Dashboard.vue:10:5', + ]), 500), + ]); + + try { + $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + $this->fail('Expected SsrException was not thrown'); + } catch (SsrException $e) { + $this->assertEquals('Foo/Bar', $e->component()); + $this->assertSame(SsrErrorType::BrowserApi, $e->type()); + $this->assertEquals('Wrap in lifecycle hook', $e->hint()); + $this->assertEquals('resources/js/Pages/Dashboard.vue:10:5', $e->sourceLocation()); + $this->assertStringContainsString('at resources/js/Pages/Dashboard.vue:10:5', $e->getMessage()); + } + } + + public function testItThrowsExceptionOnConnectionErrorWhenThrowOnErrorIsEnabled(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.throw_on_error' => true, + ]); + + Http::fake([ + $this->renderUrl => self::rejectionForFailedConnection(), + ]); + + $this->expectException(SsrException::class); + $this->expectExceptionMessage('Connection refused'); + + $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + } + + public function testItReturnsNullWhenDisabledWithBoolean(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->disable(true); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItReturnsNullWhenDisabledWithClosure(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->disable(fn () => true); + + $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testDisableWhenTakesPrecedenceOverConfig(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->gateway->disable(false); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'head' => ['SSR Test'], + 'body' => '
SSR Response
', + ])), + ]); + + $this->assertNotNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testItDoesNotThrowExceptionWhenThrowOnErrorIsDisabled(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.throw_on_error' => false, + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'error' => 'window is not defined', + 'type' => 'browser-api', + ]), 500), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + } + + public function testCircuitBreakerSkipsSsrAfterFailure(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.backoff' => 5.0, + ]); + + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'error' => 'Server down', + 'type' => 'connection', + ]), 500), + ]); + + // First dispatch fails — triggers circuit breaker + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + + // Second dispatch should be skipped (circuit breaker active) + // Even with a successful fake, gateway won't attempt the request + Http::fake([ + $this->renderUrl => Http::response(json_encode([ + 'head' => ['SSR'], + 'body' => '
SSR
', + ])), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + } + + public function testCircuitBreakerResetsAfterFlushState(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.backoff' => 5.0, + ]); + + Http::fake([ + $this->renderUrl => Http::sequence() + ->push(json_encode(['error' => 'Server down', 'type' => 'connection']), 500) + ->push(json_encode(['head' => ['SSR'], 'body' => '
SSR
'])), + ]); + + // First dispatch fails — triggers circuit breaker + $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + + // Flush resets the circuit breaker + HttpGateway::flushState(); + + // Second dispatch should succeed — circuit breaker is reset + $response = $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + $this->assertNotNull($response); + $this->assertSame('
SSR
', $response->body); + } + + public function testItHandlesScalarJsonErrorResponseGracefully(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + Http::fake([ + $this->renderUrl => Http::response('"Internal Server Error"', 500), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + + Event::assertDispatched(SsrRenderFailed::class, function (SsrRenderFailed $event) { + return $event->error === 'Unknown SSR error'; + }); + } +} From eca1f2c14f2092444da2e4e8595480d1da4da8d7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:07 +0000 Subject: [PATCH 62/83] Port MergePropTest --- tests/Inertia/MergePropTest.php | 186 ++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 tests/Inertia/MergePropTest.php diff --git a/tests/Inertia/MergePropTest.php b/tests/Inertia/MergePropTest.php new file mode 100644 index 000000000..9ff3bf168 --- /dev/null +++ b/tests/Inertia/MergePropTest.php @@ -0,0 +1,186 @@ +assertSame('A merge prop value', $mergeProp()); + } + + public function testCanInvokeWithANonCallback(): void + { + $mergeProp = new MergeProp(['key' => 'value']); + + $this->assertSame(['key' => 'value'], $mergeProp()); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $mergeProp = new MergeProp('date'); + + $this->assertSame('date', $mergeProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $mergeProp = new MergeProp(function (Request $request) { + return $request; + }); + + $this->assertInstanceOf(Request::class, $mergeProp()); + } + + public function testAppendsByDefault(): void + { + $mergeProp = new MergeProp([]); + + $this->assertTrue($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testPrepends(): void + { + $mergeProp = (new MergeProp([]))->prepend(); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertTrue($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testAppendsWithNestedMergePaths(): void + { + $mergeProp = (new MergeProp([]))->append('data'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testAppendsWithNestedMergePathsAndMatchOn(): void + { + $mergeProp = (new MergeProp([]))->append('data', 'id'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id'], $mergeProp->matchesOn()); + } + + public function testPrependsWithNestedMergePaths(): void + { + $mergeProp = (new MergeProp([]))->prepend('data'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data'], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testPrependsWithNestedMergePathsAndMatchOn(): void + { + $mergeProp = (new MergeProp([]))->prepend('data', 'id'); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data'], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id'], $mergeProp->matchesOn()); + } + + public function testAppendWithNestedMergePathsAsArray(): void + { + $mergeProp = (new MergeProp([]))->append(['data', 'items']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data', 'items'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testAppendWithNestedMergePathsAndMatchOnAsArray(): void + { + $mergeProp = (new MergeProp([]))->append(['data' => 'id', 'items' => 'uid']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data', 'items'], $mergeProp->appendsAtPaths()); + $this->assertSame([], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id', 'items.uid'], $mergeProp->matchesOn()); + } + + public function testPrependWithNestedMergePathsAsArray(): void + { + $mergeProp = (new MergeProp([]))->prepend(['data', 'items']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data', 'items'], $mergeProp->prependsAtPaths()); + $this->assertSame([], $mergeProp->matchesOn()); + } + + public function testPrependWithNestedMergePathsAndMatchOnAsArray(): void + { + $mergeProp = (new MergeProp([]))->prepend(['data' => 'id', 'items' => 'uid']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame([], $mergeProp->appendsAtPaths()); + $this->assertSame(['data', 'items'], $mergeProp->prependsAtPaths()); + $this->assertSame(['data.id', 'items.uid'], $mergeProp->matchesOn()); + } + + public function testMixOfAppendAndPrependWithNestedMergePathsAndMatchOnAsArray(): void + { + $mergeProp = (new MergeProp([])) + ->append('data') + ->append('users', 'id') + ->append(['items' => 'uid', 'posts']) + ->prepend('categories') + ->prepend('companies', 'id') + ->prepend(['tags' => 'name', 'comments']); + + $this->assertFalse($mergeProp->appendsAtRoot()); + $this->assertFalse($mergeProp->prependsAtRoot()); + $this->assertSame(['data', 'users', 'items', 'posts'], $mergeProp->appendsAtPaths()); + $this->assertSame(['categories', 'companies', 'tags', 'comments'], $mergeProp->prependsAtPaths()); + $this->assertSame(['users.id', 'items.uid', 'companies.id', 'tags.name'], $mergeProp->matchesOn()); + } + + public function testIsOnceable(): void + { + $mergeProp = (new MergeProp(fn () => [])) + ->once() + ->as('custom-key') + ->until(60); + + $this->assertTrue($mergeProp->shouldResolveOnce()); + $this->assertSame('custom-key', $mergeProp->getKey()); + $this->assertNotNull($mergeProp->expiresAt()); + } +} From 59155926231d49f664c6e02b3e473a85e7ee9bd5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:07 +0000 Subject: [PATCH 63/83] Port MiddlewareTest with version caching tests --- tests/Inertia/MiddlewareTest.php | 586 +++++++++++++++++++++++++++++++ 1 file changed, 586 insertions(+) create mode 100644 tests/Inertia/MiddlewareTest.php diff --git a/tests/Inertia/MiddlewareTest.php b/tests/Inertia/MiddlewareTest.php new file mode 100644 index 000000000..560e0e734 --- /dev/null +++ b/tests/Inertia/MiddlewareTest.php @@ -0,0 +1,586 @@ +cleanDirectory(public_path()); + } + + public function testNoResponseValueByDefaultMeansAutomaticallyRedirectingBackForInertiaRequests(): void + { + $fooCalled = false; + Route::middleware(Middleware::class)->put('/', function () use (&$fooCalled) { + $fooCalled = true; + }); + + $response = $this + ->from('/foo') + ->put('/', [], [ + 'X-Inertia' => 'true', + 'Content-Type' => 'application/json', + ]); + + $response->assertRedirect('/foo'); + $response->assertStatus(303); + $this->assertTrue($fooCalled); + } + + public function testNoResponseValueCanBeCustomizedByOverridingTheMiddlewareMethod(): void + { + Route::middleware(ExampleMiddleware::class)->get('/', function () { + // Do nothing.. + }); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('An empty Inertia response was returned.'); + + $this + ->withoutExceptionHandling() + ->from('/foo') + ->get('/', [ + 'X-Inertia' => 'true', + 'Content-Type' => 'application/json', + ]); + } + + public function testNoResponseMeansNoResponseForNonInertiaRequests(): void + { + $fooCalled = false; + Route::middleware(Middleware::class)->put('/', function () use (&$fooCalled) { + $fooCalled = true; + }); + + $response = $this + ->from('/foo') + ->put('/', [], [ + 'Content-Type' => 'application/json', + ]); + + $response->assertNoContent(200); + $this->assertTrue($fooCalled); + } + + public function testTheVersionIsOptional(): void + { + $this->prepareMockEndpoint(); + + $response = $this->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson(['component' => 'User/Edit']); + } + + public function testTheVersionCanBeANumber(): void + { + $this->prepareMockEndpoint($version = 1597347897973); + + $response = $this->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => $version, + ]); + + $response->assertSuccessful(); + $response->assertJson(['component' => 'User/Edit']); + } + + public function testTheVersionCanBeAString(): void + { + $this->prepareMockEndpoint($version = 'foo-version'); + + $response = $this->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => $version, + ]); + + $response->assertSuccessful(); + $response->assertJson(['component' => 'User/Edit']); + } + + public function testItWillInstructInertiaToReloadOnAVersionMismatch(): void + { + $this->prepareMockEndpoint('1234'); + + $response = $this->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => '4321', + ]); + + $response->assertStatus(409); + $response->assertHeader('X-Inertia-Location', $this->baseUrl); + self::assertEmpty($response->getContent()); + } + + public function testTheUrlCanBeResolvedWithACustomResolver(): void + { + $this->prepareMockEndpoint(middleware: new CustomUrlResolverMiddleware); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'url' => '/my-custom-url', + ]); + } + + public function testValidationErrorsAreRegisteredAsOfDefault(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $this->assertInstanceOf(AlwaysProp::class, Inertia::getShared('errors')); + }); + + $this->withoutExceptionHandling()->get('/'); + } + + public function testValidationErrorsCanBeEmpty(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $errors = Inertia::getShared('errors')(); + + $this->assertIsObject($errors); + $this->assertEmpty(get_object_vars($errors)); + }); + + $this->withoutExceptionHandling()->get('/'); + } + + public function testValidationErrorsAreMappedToStringsByDefault(): void + { + Session::put('errors', (new ViewErrorBag)->put('default', new MessageBag([ + 'name' => ['The name field is required.'], + 'email' => ['Not a valid email address.', 'Another email error.'], + ]))); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $errors = Inertia::getShared('errors')(); + + $this->assertIsObject($errors); + $this->assertSame('The name field is required.', $errors->name); + $this->assertSame('Not a valid email address.', $errors->email); + }); + + $this->withoutExceptionHandling()->get('/'); + } + + public function testValidationErrorsCanRemainMultiplePerField(): void + { + Session::put('errors', (new ViewErrorBag)->put('default', new MessageBag([ + 'name' => ['The name field is required.'], + 'email' => ['Not a valid email address.', 'Another email error.'], + ]))); + + Route::middleware([StartSession::class, WithAllErrorsMiddleware::class])->get('/', function () { + $errors = Inertia::getShared('errors')(); + + $this->assertIsObject($errors); + $this->assertSame(['The name field is required.'], $errors->name); + $this->assertSame( + ['Not a valid email address.', 'Another email error.'], + $errors->email + ); + }); + + $this->withoutExceptionHandling()->get('/'); + } + + public function testValidationErrorsWithNamedErrorBagsAreScoped(): void + { + Session::put('errors', (new ViewErrorBag)->put('example', new MessageBag([ + 'name' => 'The name field is required.', + 'email' => 'Not a valid email address.', + ]))); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $errors = Inertia::getShared('errors')(); + + $this->assertIsObject($errors); + $this->assertSame('The name field is required.', $errors->example->name); + $this->assertSame('Not a valid email address.', $errors->example->email); + }); + + $this->withoutExceptionHandling()->get('/'); + } + + public function testDefaultValidationErrorsCanBeOverwritten(): void + { + Session::put('errors', (new ViewErrorBag)->put('example', new MessageBag([ + 'name' => 'The name field is required.', + 'email' => 'Not a valid email address.', + ]))); + + $this->prepareMockEndpoint(null, ['errors' => 'foo']); + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertJson([ + 'props' => [ + 'errors' => 'foo', + ], + ]); + } + + public function testValidationErrorsAreScopedToErrorBagHeader(): void + { + Session::put('errors', (new ViewErrorBag)->put('default', new MessageBag([ + 'name' => 'The name field is required.', + 'email' => 'Not a valid email address.', + ]))); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $errors = Inertia::getShared('errors')(); + + $this->assertIsObject($errors); + $this->assertSame('The name field is required.', $errors->example->name); + $this->assertSame('Not a valid email address.', $errors->example->email); + }); + + $this->withoutExceptionHandling()->get('/', ['X-Inertia-Error-Bag' => 'example']); + } + + public function testMiddlewareCanChangeTheRootViewViaAProperty(): void + { + $this->prepareMockEndpoint(null, [], new class extends Middleware { + protected string $rootView = 'welcome'; + }); + + $response = $this->get('/'); + $response->assertOk(); + $response->assertViewIs('welcome'); + } + + public function testMiddlewareCanChangeTheRootViewByOverridingTheRootviewMethod(): void + { + $this->prepareMockEndpoint(null, [], new class extends Middleware { + public function rootView(Request $request): string + { + return 'welcome'; + } + }); + + $response = $this->get('/'); + $response->assertOk(); + $response->assertViewIs('welcome'); + } + + public function testDetermineTheVersionByAHashOfTheAssetUrl(): void + { + config(['app.asset_url' => $url = 'https://example.com/assets']); + + $this->prepareMockEndpoint(middleware: new Middleware); + + $response = $this->get('/'); + $response->assertOk(); + $response->assertViewHas('page.version', hash('xxh128', $url)); + } + + public function testDetermineTheVersionByAHashOfTheViteManifest(): void + { + $filesystem = new Filesystem; + $filesystem->ensureDirectoryExists(public_path('build')); + $filesystem->put( + public_path('build/manifest.json'), + $contents = json_encode(['vite' => true]) + ); + + $this->prepareMockEndpoint(middleware: new Middleware); + + $response = $this->get('/'); + $response->assertOk(); + $response->assertViewHas('page.version', hash('xxh128', $contents)); + } + + public function testDetermineTheVersionByAHashOfTheMixManifest(): void + { + $filesystem = new Filesystem; + $filesystem->ensureDirectoryExists(public_path()); + $filesystem->put( + public_path('mix-manifest.json'), + $contents = json_encode(['mix' => true]) + ); + + $this->prepareMockEndpoint(middleware: new Middleware); + + $response = $this->get('/'); + $response->assertOk(); + $response->assertViewHas('page.version', hash('xxh128', $contents)); + } + + public function testMiddlewareShareOnce(): void + { + $middleware = new class extends Middleware { + public function shareOnce(Request $request): array + { + return [ + 'permissions' => fn () => ['admin' => true], + 'settings' => Inertia::once(fn () => ['theme' => 'dark']) + ->as('app-settings') + ->until(60), + ]; + } + }; + + Route::middleware(StartSession::class)->get('/', function (Request $request) use ($middleware) { + return $middleware->handle($request, function ($request) { + return Inertia::render('User/Edit')->toResponse($request); + }); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'props' => [ + 'permissions' => ['admin' => true], + 'settings' => ['theme' => 'dark'], + ], + 'onceProps' => [ + 'permissions' => ['prop' => 'permissions', 'expiresAt' => null], + 'app-settings' => ['prop' => 'settings'], + ], + ]); + $this->assertNotNull($response->json('onceProps.app-settings.expiresAt')); + } + + public function testMiddlewareShareAndShareOnceAreMerged(): void + { + $middleware = new class extends Middleware { + public function share(Request $request): array + { + return array_merge(parent::share($request), [ + 'flash' => fn () => ['message' => 'Hello'], + ]); + } + + public function shareOnce(Request $request): array + { + return array_merge(parent::shareOnce($request), [ + 'permissions' => fn () => ['admin' => true], + ]); + } + }; + + Route::middleware(StartSession::class)->get('/', function (Request $request) use ($middleware) { + return $middleware->handle($request, function ($request) { + return Inertia::render('User/Edit')->toResponse($request); + }); + }); + + $response = $this->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'props' => [ + 'flash' => ['message' => 'Hello'], + 'permissions' => ['admin' => true], + ], + 'onceProps' => [ + 'permissions' => ['prop' => 'permissions', 'expiresAt' => null], + ], + ]); + } + + public function testFlashDataIsPreservedOnNonInertiaRedirect(): void + { + Route::middleware([StartSession::class, Middleware::class])->get('/action', function () { + Inertia::flash('message', 'Success!'); + + return redirect('/dashboard'); + }); + + Route::middleware([StartSession::class, Middleware::class])->get('/dashboard', function () { + return Inertia::render('Dashboard'); + }); + + $response = $this->get('/action'); + $response->assertRedirect('/dashboard'); + + $response = $this->get('/dashboard', ['X-Inertia' => 'true']); + $response->assertJson([ + 'flash' => ['message' => 'Success!'], + ]); + } + + public function testRedirectWithHashFragmentReturns409ForInertiaRequests(): void + { + Route::middleware([StartSession::class, Middleware::class])->get('/action', function () { + return redirect('/article#section'); + }); + + $response = $this->get('/action', [ + 'X-Inertia' => 'true', + ]); + + $response->assertStatus(409); + $response->assertHeader('X-Inertia-Redirect', $this->baseUrl . '/article#section'); + self::assertEmpty($response->getContent()); + } + + public function testRedirectWithoutHashFragmentIsNotIntercepted(): void + { + Route::middleware([StartSession::class, Middleware::class])->post('/action', function () { + return redirect('/article'); + }); + + $response = $this->post('/action', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertRedirect('/article'); + $response->assertStatus(302); + } + + public function testRedirectWithHashFragmentIsNotInterceptedForNonInertiaRequests(): void + { + Route::middleware([StartSession::class, Middleware::class])->get('/action', function () { + return redirect('/article#section'); + }); + + $response = $this->get('/action'); + + $response->assertRedirect($this->baseUrl . '/article#section'); + $response->assertStatus(302); + } + + public function testPostRedirectWithHashFragmentReturns409ForInertiaRequests(): void + { + Route::middleware([StartSession::class, Middleware::class])->post('/action', function () { + return redirect('/article#section'); + }); + + $response = $this->post('/action', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertStatus(409); + $response->assertHeader('X-Inertia-Redirect', $this->baseUrl . '/article#section'); + } + + public function testRedirectWithHashFragmentIsNotInterceptedForPrefetchRequests(): void + { + Route::middleware([StartSession::class, Middleware::class])->get('/action', function () { + return redirect('/article#section'); + }); + + $response = $this->get('/action', [ + 'X-Inertia' => 'true', + 'Purpose' => 'prefetch', + ]); + + $response->assertRedirect($this->baseUrl . '/article#section'); + } + + public function testMiddlewareRegistersSsrExceptPaths(): void + { + $middleware = new SsrExceptMiddleware; + + Route::middleware(StartSession::class)->get('/admin/dashboard', function (Request $request) use ($middleware) { + return $middleware->handle($request, function ($request) { + return Inertia::render('Admin/Dashboard')->toResponse($request); + }); + }); + + $this->get('/admin/dashboard'); + + $this->assertContains('admin/*', app(HttpGateway::class)->getExcludedPaths()); + $this->assertContains('nova/*', app(HttpGateway::class)->getExcludedPaths()); + } + + public function testVersionIsCachedForWorkerLifetime(): void + { + $filesystem = new Filesystem; + $filesystem->ensureDirectoryExists(public_path('build')); + $filesystem->put( + public_path('build/manifest.json'), + $originalContents = json_encode(['v1' => true]) + ); + + $middleware = new Middleware; + $request = Request::create('/'); + + $firstVersion = $middleware->version($request); + $this->assertSame(hash('xxh128', $originalContents), $firstVersion); + + // Change the manifest — cached version should still be returned + $filesystem->put( + public_path('build/manifest.json'), + json_encode(['v2' => true]) + ); + + $secondVersion = $middleware->version($request); + $this->assertSame($firstVersion, $secondVersion); + } + + public function testVersionCacheResetsAfterFlushState(): void + { + $filesystem = new Filesystem; + $filesystem->ensureDirectoryExists(public_path('build')); + $filesystem->put( + public_path('build/manifest.json'), + json_encode(['v1' => true]) + ); + + $middleware = new Middleware; + $request = Request::create('/'); + + $firstVersion = $middleware->version($request); + + Middleware::flushState(); + + $filesystem->put( + public_path('build/manifest.json'), + $newContents = json_encode(['v2' => true]) + ); + + $secondVersion = $middleware->version($request); + $this->assertSame(hash('xxh128', $newContents), $secondVersion); + $this->assertNotSame($firstVersion, $secondVersion); + } + + /** + * @param array $shared + */ + private function prepareMockEndpoint(int|string|null $version = null, array $shared = [], ?Middleware $middleware = null): RouteInstance + { + if (is_null($middleware)) { + $middleware = new ExampleMiddleware($version, $shared); + } + + return Route::middleware(StartSession::class)->get('/', function (Request $request) use ($middleware) { + return $middleware->handle($request, function ($request) { + return Inertia::render('User/Edit', ['user' => ['name' => 'Jonathan']])->toResponse($request); + }); + }); + } +} From 08f84119a18d8044a41d04a8b723625cdff29471 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:07 +0000 Subject: [PATCH 64/83] Port OncePropTest --- tests/Inertia/OncePropTest.php | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/Inertia/OncePropTest.php diff --git a/tests/Inertia/OncePropTest.php b/tests/Inertia/OncePropTest.php new file mode 100644 index 000000000..4c463b433 --- /dev/null +++ b/tests/Inertia/OncePropTest.php @@ -0,0 +1,89 @@ +assertSame('A once prop value', $onceProp()); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $onceProp = new OnceProp('date'); + + $this->assertSame('date', $onceProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $onceProp = new OnceProp(function (Request $request) { + return $request; + }); + + $this->assertInstanceOf(Request::class, $onceProp()); + } + + public function testCanSetCustomKey(): void + { + $onceProp = new OnceProp(fn () => 'value'); + + $result = $onceProp->as('custom-key'); + $this->assertSame($onceProp, $result); + $this->assertSame('custom-key', $onceProp->getKey()); + + $onceProp->as(TestBackedEnum::Foo); + $this->assertSame('foo-value', $onceProp->getKey()); + + $onceProp->as(TestUnitEnum::Baz); + $this->assertSame('Baz', $onceProp->getKey()); + } + + public function testShouldNotBeRefreshedByDefault(): void + { + $onceProp = new OnceProp(fn () => 'value'); + + $this->assertFalse($onceProp->shouldBeRefreshed()); + } + + public function testCanForcefullyRefresh(): void + { + $onceProp = new OnceProp(fn () => 'value'); + $onceProp->fresh(); + + $this->assertTrue($onceProp->shouldBeRefreshed()); + } + + public function testCanDisableForcefulRefresh(): void + { + $onceProp = new OnceProp(fn () => 'value'); + $onceProp->fresh(); + $onceProp->fresh(false); + + $this->assertFalse($onceProp->shouldBeRefreshed()); + } +} From b8af4dd317fb6f299e3fe97e9eb2ee90acef1409 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:07 +0000 Subject: [PATCH 65/83] Port OptionalPropTest --- tests/Inertia/OptionalPropTest.php | 52 ++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/Inertia/OptionalPropTest.php diff --git a/tests/Inertia/OptionalPropTest.php b/tests/Inertia/OptionalPropTest.php new file mode 100644 index 000000000..b47fa4d39 --- /dev/null +++ b/tests/Inertia/OptionalPropTest.php @@ -0,0 +1,52 @@ +assertSame('An optional value', $optionalProp()); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $optionalProp = new OptionalProp('date'); + + $this->assertSame('date', $optionalProp()); + } + + public function testCanResolveBindingsWhenInvoked(): void + { + $optionalProp = new OptionalProp(function (Request $request) { + return $request; + }); + + $this->assertInstanceOf(Request::class, $optionalProp()); + } + + public function testIsOnceable(): void + { + $optionalProp = (new OptionalProp(fn () => 'value')) + ->once() + ->as('custom-key') + ->until(60); + + $this->assertTrue($optionalProp->shouldResolveOnce()); + $this->assertSame('custom-key', $optionalProp->getKey()); + $this->assertNotNull($optionalProp->expiresAt()); + } +} From 8689881a73b2c05855be133211b6238a1f9165ea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:07 +0000 Subject: [PATCH 66/83] Port PropsResolverTest --- tests/Inertia/PropsResolverTest.php | 1080 +++++++++++++++++++++++++++ 1 file changed, 1080 insertions(+) create mode 100644 tests/Inertia/PropsResolverTest.php diff --git a/tests/Inertia/PropsResolverTest.php b/tests/Inertia/PropsResolverTest.php new file mode 100644 index 000000000..890d49e6f --- /dev/null +++ b/tests/Inertia/PropsResolverTest.php @@ -0,0 +1,1080 @@ +makePage(Request::create('/'), [ + 'auth' => fn () => ['user' => 'Jonathan'], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + } + + public function testNestedClosureInsideArrayIsResolved(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => fn () => 'Jonathan', + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + } + + public function testNestedProvidesInertiaPropertiesIsResolved(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return ['user' => 'Jonathan', 'role' => 'admin']; + } + }, + 'team' => 'Inertia', + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertSame('admin', $page['props']['auth']['role']); + $this->assertSame('Inertia', $page['props']['auth']['team']); + } + + public function testNestedProvidesInertiaPropertiesWithPropTypes(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'user' => 'Jonathan', + 'permissions' => Inertia::optional(fn () => ['manage-users']), + ]; + } + }, + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('permissions', $page['props']['auth']); + } + + public function testNestedProvidesInertiaPropertiesWithPropTypesOnPartialRequest(): void + { + $page = $this->makePage( + $this->makePartialRequest('auth.permissions'), + [ + 'auth' => [ + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'user' => 'Jonathan', + 'permissions' => Inertia::optional(fn () => ['manage-users']), + ]; + } + }, + ], + ], + ); + + $this->assertArrayNotHasKey('user', $page['props']['auth']); + $this->assertSame(['manage-users'], $page['props']['auth']['permissions']); + } + + public function testNestedAlwaysPropIsResolved(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => Inertia::always(fn () => 'Jonathan'), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + } + + public function testNestedMergePropIsResolved(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame([['id' => 1]], $page['props']['feed']['posts']); + } + + public function testNestedOncePropIsResolvedOnInitialLoad(): void + { + $page = $this->makePage(Request::create('/'), [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame('en', $page['props']['config']['locale']); + } + + public function testNestedOptionalPropIsExcludedFromInitialLoad(): void + { + $resolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'permissions' => Inertia::optional(function () use (&$resolved) { + $resolved = true; + + return ['admin']; + }), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('permissions', $page['props']['auth']); + $this->assertFalse($resolved, 'OptionalProp closure should not be resolved on initial load'); + } + + public function testNestedDeferPropIsExcludedFromInitialLoad(): void + { + $resolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'notifications' => Inertia::defer(function () use (&$resolved) { + $resolved = true; + + return []; + }), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('notifications', $page['props']['auth']); + $this->assertFalse($resolved, 'DeferProp closure should not be resolved on initial load'); + } + + public function testExcludedPropsAreNotResolvedOnInitialLoad(): void + { + $optionalResolved = false; + $deferResolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'name' => 'Jonathan', + 'permissions' => Inertia::optional(function () use (&$optionalResolved) { + $optionalResolved = true; + + return ['admin']; + }), + 'notifications' => Inertia::defer(function () use (&$deferResolved) { + $deferResolved = true; + + return ['You have a new follower']; + }), + ]); + + $this->assertSame('Jonathan', $page['props']['name']); + $this->assertArrayNotHasKey('permissions', $page['props']); + $this->assertArrayNotHasKey('notifications', $page['props']); + $this->assertFalse($optionalResolved, 'OptionalProp closure should not be resolved on initial load'); + $this->assertFalse($deferResolved, 'DeferProp closure should not be resolved on initial load'); + } + + public function testClosureReturningOptionalPropIsExcludedFromInitialLoad(): void + { + $resolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'auth' => fn () => [ + 'user' => 'Jonathan', + 'permissions' => Inertia::optional(function () use (&$resolved) { + $resolved = true; + + return ['admin']; + }), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('permissions', $page['props']['auth']); + $this->assertFalse($resolved, 'OptionalProp closure should not be resolved on initial load'); // @phpstan-ignore method.impossibleType + } + + public function testClosureReturningDeferPropIsExcludedFromInitialLoad(): void + { + $resolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'auth' => fn () => [ + 'user' => 'Jonathan', + 'notifications' => Inertia::defer(function () use (&$resolved) { + $resolved = true; + + return []; + }), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('notifications', $page['props']['auth']); + $this->assertFalse($resolved, 'DeferProp closure should not be resolved on initial load'); // @phpstan-ignore method.impossibleType + } + + public function testClosureReturningMergePropResolvesWithMetadata(): void + { + $page = $this->makePage(Request::create('/'), [ + 'posts' => fn () => new MergeProp([['id' => 1]]), + ]); + + $this->assertSame([['id' => 1]], $page['props']['posts']); + $this->assertSame(['posts'], $page['mergeProps']); + } + + public function testClosureReturningOncePropResolvesWithMetadata(): void + { + $page = $this->makePage(Request::create('/'), [ + 'locale' => fn () => Inertia::once(fn () => 'en'), + ]); + + $this->assertSame('en', $page['props']['locale']); + $this->assertSame(['locale' => ['prop' => 'locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testClosureReturningDeferPropCollectsDeferredAndMergeMetadata(): void + { + $page = $this->makePage(Request::create('/'), [ + 'posts' => fn () => Inertia::defer(fn () => [['id' => 1]])->merge(), + ]); + + $this->assertArrayNotHasKey('posts', $page['props']); + $this->assertSame(['default' => ['posts']], $page['deferredProps']); + $this->assertSame(['posts'], $page['mergeProps']); + } + + public function testNestedOptionalPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('auth.permissions'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'permissions' => Inertia::optional(fn () => ['admin']), + ], + ]); + + $this->assertSame(['admin'], $page['props']['auth']['permissions']); + } + + public function testNestedDeferPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('auth.notifications'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'notifications' => Inertia::defer(fn () => ['new message']), + ], + ]); + + $this->assertSame(['new message'], $page['props']['auth']['notifications']); + } + + public function testNestedAlwaysPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('auth.user'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'errors' => Inertia::always(fn () => ['name' => 'required']), + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertSame(['name' => 'required'], $page['props']['auth']['errors']); + } + + public function testTopLevelAlwaysPropIsIncludedWhenNotRequested(): void + { + $page = $this->makePage($this->makePartialRequest('other'), [ + 'other' => 'value', + 'errors' => Inertia::always(fn () => ['name' => 'required']), + ]); + + $this->assertSame('value', $page['props']['other']); + $this->assertSame(['name' => 'required'], $page['props']['errors']); + } + + public function testNestedMergePropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('feed.posts'), [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame([['id' => 1]], $page['props']['feed']['posts']); + } + + public function testNestedPropIsExcludedViaExceptHeader(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'TestComponent']); + $request->headers->add(['X-Inertia-Partial-Data' => 'auth']); + $request->headers->add(['X-Inertia-Partial-Except' => 'auth.token']); + + $page = $this->makePage($request, [ + 'auth' => [ + 'user' => 'Jonathan', + 'token' => 'secret', + ], + ]); + + $this->assertSame('Jonathan', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('token', $page['props']['auth']); + } + + public function testPartialRequestForParentResolvesAllNestedPropTypes(): void + { + $page = $this->makePage($this->makePartialRequest('dashboard'), [ + 'dashboard' => [ + 'stats' => 'visible', + 'feed' => new MergeProp([['id' => 1]]), + 'notifications' => Inertia::defer(fn () => ['msg']), + 'settings' => Inertia::optional(fn () => ['theme' => 'dark']), + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame('visible', $page['props']['dashboard']['stats']); + $this->assertSame([['id' => 1]], $page['props']['dashboard']['feed']); + $this->assertSame(['msg'], $page['props']['dashboard']['notifications']); + $this->assertSame(['theme' => 'dark'], $page['props']['dashboard']['settings']); + $this->assertSame('en', $page['props']['dashboard']['locale']); + $this->assertSame(['dashboard.feed'], $page['mergeProps']); + $this->assertSame(['dashboard.locale' => ['prop' => 'dashboard.locale', 'expiresAt' => null]], $page['onceProps']); + $this->assertArrayNotHasKey('deferredProps', $page); + } + + public function testNestedDeferPropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => 'Jonathan', + 'notifications' => Inertia::defer(fn () => []), + ], + ]); + + $this->assertSame(['default' => ['auth.notifications']], $page['deferredProps']); + } + + public function testNestedDeferPropMetadataPreservesGroup(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'notifications' => Inertia::defer(fn () => [], 'sidebar'), + 'messages' => Inertia::defer(fn () => [], 'sidebar'), + ], + ]); + + $this->assertSame(['sidebar' => ['auth.notifications', 'auth.messages']], $page['deferredProps']); + } + + public function testClosureReturningDeferPropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => fn () => [ + 'user' => 'Jonathan', + 'notifications' => Inertia::defer(fn () => [], 'alerts'), + ], + ]); + + $this->assertSame(['alerts' => ['auth.notifications']], $page['deferredProps']); + } + + public function testNestedMergePropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame(['feed.posts'], $page['mergeProps']); + } + + public function testNestedPrependMergePropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => (new MergeProp([['id' => 1]]))->prepend(), + ], + ]); + + $this->assertSame(['feed.posts'], $page['prependProps']); + } + + public function testNestedDeepMergePropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'settings' => [ + 'preferences' => (new MergeProp(['theme' => 'dark']))->deepMerge(), + ], + ]); + + $this->assertSame(['settings.preferences'], $page['deepMergeProps']); + } + + public function testNestedMergePropWithNestedPathMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => (new MergeProp(['data' => [['id' => 1]]]))->append('data'), + ], + ]); + + $this->assertSame(['feed.posts.data'], $page['mergeProps']); + } + + public function testNestedMergePropWithMatchOnMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => (new MergeProp([['id' => 1]]))->matchOn('id')->deepMerge(), + ], + ]); + + $this->assertSame(['feed.posts'], $page['deepMergeProps']); + $this->assertSame(['feed.posts.id'], $page['matchPropsOn']); + } + + public function testNestedDeferWithMergeMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => Inertia::defer(fn () => [['id' => 1]])->merge(), + ], + ]); + + $this->assertSame(['default' => ['feed.posts']], $page['deferredProps']); + $this->assertSame(['feed.posts'], $page['mergeProps']); + } + + public function testNestedMergeMetadataIsCollectedOnExactPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('feed.posts'), [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame([['id' => 1]], $page['props']['feed']['posts']); + $this->assertSame(['feed.posts'], $page['mergeProps']); + } + + public function testNestedMergeMetadataIsCollectedWhenParentIsRequested(): void + { + $page = $this->makePage($this->makePartialRequest('feed'), [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame([['id' => 1]], $page['props']['feed']['posts']); + $this->assertSame(['feed.posts'], $page['mergeProps']); + } + + public function testNestedMergePropMetadataIsSuppressedByResetHeader(): void + { + $request = $this->makePartialRequest('feed.posts'); + $request->headers->add(['X-Inertia-Reset' => 'feed.posts']); + + $page = $this->makePage($request, [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ]); + + $this->assertSame([['id' => 1]], $page['props']['feed']['posts']); + $this->assertArrayNotHasKey('mergeProps', $page); + } + + public function testNestedOncePropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame(['config.locale' => ['prop' => 'config.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedOncePropWithCustomKeyMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en')->as('app-locale'), + ], + ]); + + $this->assertSame(['app-locale' => ['prop' => 'config.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedOncePropIsExcludedWhenAlreadyLoaded(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'config.locale']); + + $page = $this->makePage($request, [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en'), + 'timezone' => 'UTC', + ], + ]); + + $this->assertSame('UTC', $page['props']['config']['timezone']); + $this->assertArrayNotHasKey('locale', $page['props']['config']); + $this->assertSame(['config.locale' => ['prop' => 'config.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedOnceMetadataIsCollectedOnExactPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('config.locale'), [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame('en', $page['props']['config']['locale']); + $this->assertSame(['config.locale' => ['prop' => 'config.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedOnceMetadataIsCollectedWhenParentIsRequested(): void + { + $page = $this->makePage($this->makePartialRequest('config'), [ + 'config' => [ + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame('en', $page['props']['config']['locale']); + $this->assertSame(['config.locale' => ['prop' => 'config.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedScrollPropMetadataIsCollected(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => new ScrollProp( + ['data' => [['id' => 1]]], + 'data', + $this->makeScrollMetadata(), + ), + ], + ]); + + $this->assertSame([ + 'feed.posts' => [ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + 'reset' => false, + ], + ], $page['scrollProps']); + } + + public function testNestedDeferredScrollPropIsExcludedFromInitialLoad(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => (new ScrollProp( + ['data' => [['id' => 1]]], + 'data', + $this->makeScrollMetadata(), + ))->defer(), + ], + ]); + + $this->assertArrayNotHasKey('posts', $page['props']['feed'] ?? []); + $this->assertSame(['default' => ['feed.posts']], $page['deferredProps']); + } + + public function testNestedScrollPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('feed.posts'), [ + 'feed' => [ + 'posts' => new ScrollProp( + ['data' => [['id' => 1]]], + 'data', + $this->makeScrollMetadata(), + ), + ], + ]); + + $this->assertSame(['data' => [['id' => 1]]], $page['props']['feed']['posts']); + $this->assertSame([ + 'feed.posts' => [ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + 'reset' => false, + ], + ], $page['scrollProps']); + } + + public function testNestedScrollPropResetFlagIsSetByResetHeader(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia-Reset' => 'feed.posts']); + + $page = $this->makePage($request, [ + 'feed' => [ + 'posts' => new ScrollProp( + ['data' => [['id' => 1]]], + 'data', + $this->makeScrollMetadata(), + ), + ], + ]); + + $this->assertTrue($page['scrollProps']['feed.posts']['reset']); + } + + public function testNestedDeferOncePropSuppressesDeferredMetadataWhenAlreadyLoaded(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'feed.posts']); + + $page = $this->makePage($request, [ + 'feed' => [ + 'posts' => Inertia::defer(fn () => [])->once(), + ], + ]); + + $this->assertArrayNotHasKey('deferredProps', $page); + } + + public function testNestedDeferOncePropIncludesDeferredMetadataOnFirstLoad(): void + { + $page = $this->makePage(Request::create('/'), [ + 'feed' => [ + 'posts' => Inertia::defer(fn () => [])->once(), + ], + ]); + + $this->assertSame(['default' => ['feed.posts']], $page['deferredProps']); + $this->assertSame(['feed.posts' => ['prop' => 'feed.posts', 'expiresAt' => null]], $page['onceProps']); + } + + public function testNestedPropsOnNonPartialInertiaRequestBehaveLikeInitialLoad(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + + $page = $this->makePage($request, [ + 'dashboard' => [ + 'stats' => 'visible', + 'feed' => new MergeProp([['id' => 1]]), + 'notifications' => Inertia::defer(fn () => []), + 'settings' => Inertia::optional(fn () => []), + ], + ]); + + $this->assertSame('visible', $page['props']['dashboard']['stats']); + $this->assertSame([['id' => 1]], $page['props']['dashboard']['feed']); + $this->assertArrayNotHasKey('notifications', $page['props']['dashboard']); + $this->assertArrayNotHasKey('settings', $page['props']['dashboard']); + $this->assertSame(['dashboard.feed'], $page['mergeProps']); + $this->assertSame(['default' => ['dashboard.notifications']], $page['deferredProps']); + } + + public function testExceptHeaderSuppressesNestedMergeMetadata(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'TestComponent']); + $request->headers->add(['X-Inertia-Partial-Data' => 'feed.posts,feed.comments']); + $request->headers->add(['X-Inertia-Partial-Except' => 'feed.posts']); + + $page = $this->makePage($request, [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + 'comments' => new MergeProp([['id' => 2]]), + ], + ]); + + $this->assertArrayNotHasKey('posts', $page['props']['feed']); + $this->assertSame([['id' => 2]], $page['props']['feed']['comments']); + $this->assertSame(['feed.comments'], $page['mergeProps']); + } + + public function testExceptHeaderForParentSuppressesAllNestedMetadata(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'TestComponent']); + $request->headers->add(['X-Inertia-Partial-Data' => 'feed,other']); + $request->headers->add(['X-Inertia-Partial-Except' => 'feed']); + + $page = $this->makePage($request, [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + 'other' => 'value', + ]); + + $this->assertArrayNotHasKey('feed', $page['props']); + $this->assertSame('value', $page['props']['other']); + $this->assertArrayNotHasKey('mergeProps', $page); + } + + public function testDeeplyNestedDeferPropIsExcludedWithMetadata(): void + { + $page = $this->makePage(Request::create('/'), [ + 'app' => [ + 'auth' => [ + 'notifications' => Inertia::defer(fn () => [], 'alerts'), + ], + ], + ]); + + $this->assertArrayNotHasKey('notifications', $page['props']['app']['auth']); + $this->assertSame(['alerts' => ['app.auth.notifications']], $page['deferredProps']); + } + + public function testDeeplyNestedMergePropMetadataUsesFullPath(): void + { + $page = $this->makePage(Request::create('/'), [ + 'app' => [ + 'feed' => [ + 'posts' => new MergeProp([['id' => 1]]), + ], + ], + ]); + + $this->assertSame(['app.feed.posts'], $page['mergeProps']); + } + + public function testDeeplyNestedOptionalPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('app.auth.permissions'), [ + 'app' => [ + 'auth' => [ + 'permissions' => Inertia::optional(fn () => ['admin']), + ], + ], + ]); + + $this->assertSame(['admin'], $page['props']['app']['auth']['permissions']); + } + + public function testMultipleNestedPropTypesAreHandledTogether(): void + { + $page = $this->makePage(Request::create('/'), [ + 'dashboard' => [ + 'stats' => 'visible', + 'feed' => new MergeProp([['id' => 1]]), + 'notifications' => Inertia::defer(fn () => []), + 'settings' => Inertia::optional(fn () => []), + 'locale' => Inertia::once(fn () => 'en'), + ], + ]); + + $this->assertSame('visible', $page['props']['dashboard']['stats']); + $this->assertSame([['id' => 1]], $page['props']['dashboard']['feed']); + $this->assertSame('en', $page['props']['dashboard']['locale']); + $this->assertArrayNotHasKey('notifications', $page['props']['dashboard']); + $this->assertArrayNotHasKey('settings', $page['props']['dashboard']); + $this->assertSame(['dashboard.feed'], $page['mergeProps']); + $this->assertSame(['default' => ['dashboard.notifications']], $page['deferredProps']); + $this->assertSame(['dashboard.locale' => ['prop' => 'dashboard.locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testDeferredPropsAtMixedDepthsCollectCorrectMetadata(): void + { + $page = $this->makePage(Request::create('/'), [ + 'foo' => Inertia::defer(fn () => 'bar'), + 'nested' => [ + 'a' => 'b', + 'c' => Inertia::defer(fn () => 'd'), + ], + ]); + + $this->assertSame('b', $page['props']['nested']['a']); + $this->assertArrayNotHasKey('foo', $page['props']); + $this->assertArrayNotHasKey('c', $page['props']['nested']); + $this->assertSame(['default' => ['foo', 'nested.c']], $page['deferredProps']); + } + + public function testDeferredPropsAtMixedDepthsResolveOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('foo,nested.c'), [ + 'foo' => Inertia::defer(fn () => 'bar'), + 'nested' => [ + 'a' => 'b', + 'c' => Inertia::defer(fn () => 'd'), + ], + ]); + + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame('d', $page['props']['nested']['c']); + $this->assertArrayNotHasKey('a', $page['props']['nested']); + $this->assertArrayNotHasKey('deferredProps', $page); + } + + public function testDotNotationPropMergesIntoExistingNestedStructure(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + ], + 'auth.user.permissions' => fn () => ['edit-posts', 'delete-posts'], + ]); + + $this->assertSame('Jonathan Reinink', $page['props']['auth']['user']['name']); + $this->assertSame('jonathan@example.com', $page['props']['auth']['user']['email']); + $this->assertSame(['edit-posts', 'delete-posts'], $page['props']['auth']['user']['permissions']); + $this->assertArrayNotHasKey('auth.user.permissions', $page['props']); + } + + public function testDotNotationPropMergesWhenParentIsAClosure(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => fn () => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + ], + 'auth.user.permissions' => fn () => ['edit-posts', 'delete-posts'], + ]); + + $this->assertSame('Jonathan Reinink', $page['props']['auth']['user']['name']); + $this->assertSame('jonathan@example.com', $page['props']['auth']['user']['email']); + $this->assertSame(['edit-posts', 'delete-posts'], $page['props']['auth']['user']['permissions']); + } + + public function testDotNotationOptionalPropIsExcludedFromInitialLoad(): void + { + $page = $this->makePage(Request::create('/'), [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + ], + 'auth.user.permissions' => Inertia::optional(fn () => ['edit-posts', 'delete-posts']), + ]); + + $this->assertSame('Jonathan Reinink', $page['props']['auth']['user']['name']); + $this->assertSame('jonathan@example.com', $page['props']['auth']['user']['email']); + $this->assertArrayNotHasKey('permissions', $page['props']['auth']['user']); + $this->assertArrayNotHasKey('auth.user.permissions', $page['props']); + } + + public function testDotNotationOptionalPropIsIncludedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('auth.user.permissions'), [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + ], + 'auth.user.permissions' => Inertia::optional(fn () => ['edit-posts', 'delete-posts']), + ]); + + $this->assertSame(['edit-posts', 'delete-posts'], $page['props']['auth']['user']['permissions']); + $this->assertArrayNotHasKey('auth.user.permissions', $page['props']); + } + + public function testOptionalPropsInsideIndexedArraysAreResolvedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('foos'), [ + 'foos' => [ + [ + 'name' => 'First', + 'bar' => Inertia::optional(fn () => 'expensive-data-1'), + ], + [ + 'name' => 'Second', + 'bar' => Inertia::optional(fn () => 'expensive-data-2'), + ], + ], + ]); + + $this->assertSame('First', $page['props']['foos'][0]['name']); + $this->assertSame('expensive-data-1', $page['props']['foos'][0]['bar']); + $this->assertSame('Second', $page['props']['foos'][1]['name']); + $this->assertSame('expensive-data-2', $page['props']['foos'][1]['bar']); + } + + public function testOptionalPropsInsideIndexedArraysAreExcludedFromInitialLoad(): void + { + $resolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'foos' => [ + [ + 'name' => 'First', + 'bar' => Inertia::optional(function () use (&$resolved) { + $resolved = true; + + return 'expensive-data'; + }), + ], + ], + ]); + + $this->assertSame('First', $page['props']['foos'][0]['name']); + $this->assertArrayNotHasKey('bar', $page['props']['foos'][0]); + $this->assertFalse($resolved, 'OptionalProp closure should not be resolved on initial load'); + } + + public function testDeferredPropsInsideClosureAreExcludedFromInitialLoad(): void + { + $notificationsResolved = false; + $rolesResolved = false; + + $page = $this->makePage(Request::create('/'), [ + 'auth' => fn () => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + 'notifications' => Inertia::defer(function () use (&$notificationsResolved) { + $notificationsResolved = true; + + return ['You have a new follower']; + }), + 'roles' => Inertia::defer(function () use (&$rolesResolved) { + $rolesResolved = true; + + return ['admin']; + }), + ], + ]); + + $this->assertSame('Jonathan Reinink', $page['props']['auth']['user']['name']); + $this->assertArrayNotHasKey('notifications', $page['props']['auth']); + $this->assertArrayNotHasKey('roles', $page['props']['auth']); + $this->assertSame(['default' => ['auth.notifications', 'auth.roles']], $page['deferredProps']); + $this->assertFalse($notificationsResolved, 'DeferProp closure should not be resolved on initial load'); // @phpstan-ignore method.impossibleType + $this->assertFalse($rolesResolved, 'DeferProp closure should not be resolved on initial load'); // @phpstan-ignore method.impossibleType + } + + public function testDeferredPropsInsideClosureAreResolvedOnPartialRequest(): void + { + $page = $this->makePage($this->makePartialRequest('auth.notifications,auth.roles'), [ + 'auth' => fn () => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ], + 'notifications' => Inertia::defer(fn () => ['You have a new follower']), + 'roles' => Inertia::defer(fn () => ['admin']), + ], + ]); + + $this->assertSame(['You have a new follower'], $page['props']['auth']['notifications']); + $this->assertSame(['admin'], $page['props']['auth']['roles']); + } + + public function testArraysMatchingCallableSyntaxAreNotInvoked(): void + { + $page = $this->makePage(Request::create('/'), [ + 'job' => [ + 'name' => 'Import', + 'fields' => ['Context', 'comment'], + ], + ]); + + $this->assertSame(['Context', 'comment'], $page['props']['job']['fields']); + } + + /** + * Resolve the given props through the Inertia response and return the page data. + * + * @param array $props + * @return array + */ + protected function makePage(Request $request, array $props): array + { + $response = new Response('TestComponent', [], $props, 'app', '123'); + $response = $response->toResponse($request); + + if ($response instanceof JsonResponse) { + return $response->getData(true); + } + + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + + return $view->getData()['page']; + } + + /** + * Create a partial Inertia request for the given props. + */ + protected function makePartialRequest(string $only): Request + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'TestComponent']); + $request->headers->add(['X-Inertia-Partial-Data' => $only]); + + return $request; + } + + /** + * Create a scroll metadata provider for testing. + */ + protected function makeScrollMetadata(): ProvidesScrollMetadata + { + return new class implements ProvidesScrollMetadata { + public function getPageName(): string + { + return 'page'; + } + + public function getPreviousPage(): ?int + { + return null; + } + + public function getNextPage(): int + { + return 2; + } + + public function getCurrentPage(): int + { + return 1; + } + }; + } +} From 4d81b0a08f1a2a559f242a3da0556122ef2a1b47 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:19 +0000 Subject: [PATCH 67/83] Port ResponseFactoryTest --- tests/Inertia/ResponseFactoryTest.php | 1003 +++++++++++++++++++++++++ 1 file changed, 1003 insertions(+) create mode 100644 tests/Inertia/ResponseFactoryTest.php diff --git a/tests/Inertia/ResponseFactoryTest.php b/tests/Inertia/ResponseFactoryTest.php new file mode 100644 index 000000000..37795ed6e --- /dev/null +++ b/tests/Inertia/ResponseFactoryTest.php @@ -0,0 +1,1003 @@ +macro('foo', function () { + return 'bar'; + }); + + /* @phpstan-ignore-next-line */ + $this->assertEquals('bar', $factory->foo()); + } + + public function testLocationResponseForInertiaRequests(): void + { + Request::macro('inertia', function () { + return true; + }); + + $response = (new ResponseFactory)->location('https://inertiajs.com'); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(Response::HTTP_CONFLICT, $response->getStatusCode()); + $this->assertEquals('https://inertiajs.com', $response->headers->get('X-Inertia-Location')); + } + + public function testLocationResponseForNonInertiaRequests(): void + { + Request::macro('inertia', function () { + return false; + }); + + $response = (new ResponseFactory)->location('https://inertiajs.com'); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertEquals('https://inertiajs.com', $response->headers->get('location')); + } + + public function testLocationResponseForInertiaRequestsUsingRedirectResponse(): void + { + Request::macro('inertia', function () { + return true; + }); + + $redirect = new RedirectResponse('https://inertiajs.com'); + $response = (new ResponseFactory)->location($redirect); + + $this->assertInstanceOf(Response::class, $response); + $this->assertEquals(409, $response->getStatusCode()); + $this->assertEquals('https://inertiajs.com', $response->headers->get('X-Inertia-Location')); + } + + public function testLocationResponseForNonInertiaRequestsUsingRedirectResponse(): void + { + $redirect = new RedirectResponse('https://inertiajs.com'); + $response = (new ResponseFactory)->location($redirect); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertEquals('https://inertiajs.com', $response->headers->get('location')); + } + + public function testLocationRedirectsAreNotModified(): void + { + $response = (new ResponseFactory)->location('/foo'); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertEquals('/foo', $response->headers->get('location')); + } + + public function testLocationResponseForNonInertiaRequestsUsingRedirectResponseWithExistingSessionAndRequestProperties(): void + { + $redirect = new RedirectResponse('https://inertiajs.com'); + $redirect->setSession($session = new Store('test', new NullSessionHandler)); + $redirect->setRequest($request = new HttpRequest); + $response = (new ResponseFactory)->location($redirect); + + $this->assertInstanceOf(RedirectResponse::class, $response); + $this->assertEquals(Response::HTTP_FOUND, $response->getStatusCode()); + $this->assertEquals('https://inertiajs.com', $response->headers->get('location')); + $this->assertSame($session, $response->getSession()); + $this->assertSame($request, $response->getRequest()); + $this->assertSame($response, $redirect); + } + + public function testTheVersionCanBeAClosure(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $this->assertSame('', Inertia::getVersion()); + + Inertia::version(function () { + return hash('xxh128', 'Inertia'); + }); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Version' => 'f445bd0a2c393a5af14fc677f59980a9', + ]); + + $response->assertSuccessful(); + $response->assertJson(['component' => 'User/Edit']); + } + + public function testTheUrlCanBeResolvedWithACustomResolver(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::resolveUrlUsing(function ($request, ResponseFactory $otherDependency) { + $this->assertInstanceOf(HttpRequest::class, $request); + $this->assertInstanceOf(ResponseFactory::class, $otherDependency); + + return '/my-custom-url'; + }); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'url' => '/my-custom-url', + ]); + } + + public function testSharedDataCanBeSharedFromAnywhere(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('foo', 'bar'); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'foo' => 'bar', + ], + ]); + } + + public function testDotPropsAreMergedFromShared(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth.user', [ + 'name' => 'Jonathan', + ]); + + return Inertia::render('User/Edit', [ + 'auth.user.can.create_group' => false, + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan', + 'can' => [ + 'create_group' => false, + ], + ], + ], + ], + ]); + } + + public function testSharedDataCanResolveClosureArguments(): void + { + Inertia::share('query', fn (HttpRequest $request) => $request->query()); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/?foo=bar', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'query' => [ + 'foo' => 'bar', + ], + ], + ]); + } + + public function testDotPropsWithCallbacksAreMergedFromShared(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth.user', fn () => [ + 'name' => 'Jonathan', + ]); + + return Inertia::render('User/Edit', [ + 'auth.user.can.create_group' => false, + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan', + 'can' => [ + 'create_group' => false, + ], + ], + ], + ], + ]); + } + + public function testCanFlushSharedData(): void + { + Inertia::share('foo', 'bar'); + $this->assertSame(['foo' => 'bar'], Inertia::getShared()); + Inertia::flushShared(); + $this->assertSame([], Inertia::getShared()); + } + + public function testCanCreateDeferredProp(): void + { + $factory = new ResponseFactory; + $deferredProp = $factory->defer(function () { + return 'A deferred value'; + }); + + $this->assertInstanceOf(DeferProp::class, $deferredProp); + $this->assertSame($deferredProp->group(), 'default'); + } + + public function testCanCreateDeferredPropWithCustomGroup(): void + { + $factory = new ResponseFactory; + $deferredProp = $factory->defer(function () { + return 'A deferred value'; + }, 'foo'); + + $this->assertInstanceOf(DeferProp::class, $deferredProp); + $this->assertSame($deferredProp->group(), 'foo'); + } + + public function testCanCreateMergedProp(): void + { + $factory = new ResponseFactory; + $mergedProp = $factory->merge(function () { + return 'A merged value'; + }); + + $this->assertInstanceOf(MergeProp::class, $mergedProp); + } + + public function testCanCreateDeepMergedProp(): void + { + $factory = new ResponseFactory; + $mergedProp = $factory->deepMerge(function () { + return 'A merged value'; + }); + + $this->assertInstanceOf(MergeProp::class, $mergedProp); + } + + public function testCanCreateDeferredAndMergedProp(): void + { + $factory = new ResponseFactory; + $deferredProp = $factory->defer(function () { + return 'A deferred + merged value'; + })->merge(); + + $this->assertInstanceOf(DeferProp::class, $deferredProp); + } + + public function testCanCreateDeferredAndDeepMergedProp(): void + { + $factory = new ResponseFactory; + $deferredProp = $factory->defer(function () { + return 'A deferred + merged value'; + })->deepMerge(); + + $this->assertInstanceOf(DeferProp::class, $deferredProp); + } + + public function testCanCreateOptionalProp(): void + { + $factory = new ResponseFactory; + $optionalProp = $factory->optional(function () { + return 'An optional value'; + }); + + $this->assertInstanceOf(OptionalProp::class, $optionalProp); + } + + public function testCanCreateScrollProp(): void + { + $factory = new ResponseFactory; + $data = ['item1', 'item2']; + + $scrollProp = $factory->scroll($data); + + $this->assertInstanceOf(ScrollProp::class, $scrollProp); + $this->assertSame($data, $scrollProp()); + } + + public function testCanCreateScrollPropWithMetadataProvider(): void + { + $factory = new ResponseFactory; + $data = ['item1', 'item2']; + $metadataProvider = new ScrollMetadata('custom', 1, 3, 2); + + $scrollProp = $factory->scroll($data, 'data', $metadataProvider); + + $this->assertInstanceOf(ScrollProp::class, $scrollProp); + $this->assertSame($data, $scrollProp()); + $this->assertEquals([ + 'pageName' => 'custom', + 'previousPage' => 1, + 'nextPage' => 3, + 'currentPage' => 2, + ], $scrollProp->metadata()); + } + + public function testCanCreateOnceProp(): void + { + $factory = new ResponseFactory; + $onceProp = $factory->once(function () { + return 'A once value'; + }); + + $this->assertInstanceOf(OnceProp::class, $onceProp); + } + + public function testCanCreateDeferredAndOnceProp(): void + { + $factory = new ResponseFactory; + $deferredProp = $factory->defer(function () { + return 'A deferred + once value'; + })->once(); + + $this->assertInstanceOf(DeferProp::class, $deferredProp); + $this->assertTrue($deferredProp->shouldResolveOnce()); + } + + public function testCanCreateAlwaysProp(): void + { + $factory = new ResponseFactory; + $alwaysProp = $factory->always(function () { + return 'An always value'; + }); + + $this->assertInstanceOf(AlwaysProp::class, $alwaysProp); + } + + public function testWillAcceptArrayabeProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('foo', 'bar'); + + return Inertia::render('User/Edit', new class implements Arrayable { + public function toArray(): array + { + return [ + 'foo' => 'bar', + ]; + } + }); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'foo' => 'bar', + ], + ]); + } + + public function testWillAcceptInstancesOfProvidesInertiaProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit', new ExampleInertiaPropsProvider([ + 'foo' => 'bar', + ])); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'foo' => 'bar', + ], + ]); + } + + public function testWillAcceptArraysContainingProvidesInertiaPropsInRender(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit', [ + 'regular' => 'prop', + new ExampleInertiaPropsProvider([ + 'from_object' => 'value', + ]), + 'another' => 'normal_prop', + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'regular' => 'prop', + 'from_object' => 'value', + 'another' => 'normal_prop', + ], + ]); + } + + public function testCanShareInstancesOfProvidesInertiaProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share(new ExampleInertiaPropsProvider([ + 'shared' => 'data', + ])); + + return Inertia::render('User/Edit', [ + 'regular' => 'prop', + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'shared' => 'data', + 'regular' => 'prop', + ], + ]); + } + + public function testCanShareArraysContainingProvidesInertiaProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share([ + 'regular' => 'shared_prop', + new ExampleInertiaPropsProvider([ + 'from_object' => 'shared_value', + ]), + ]); + + return Inertia::render('User/Edit', [ + 'component' => 'prop', + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'regular' => 'shared_prop', + 'from_object' => 'shared_value', + 'component' => 'prop', + ], + ]); + } + + public function testWillThrowExceptionIfComponentDoesNotExistWhenEnsuringIsEnabled(): void + { + config()->set('inertia.pages.ensure_pages_exist', true); + + $this->expectException(ComponentNotFoundException::class); + $this->expectExceptionMessage('Inertia page component [foo] not found.'); + + (new ResponseFactory)->render('foo'); + } + + public function testWillNotThrowExceptionIfComponentDoesNotExistWhenEnsuringIsDisabled(): void + { + config()->set('inertia.pages.ensure_pages_exist', false); + + $response = (new ResponseFactory)->render('foo'); + $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response); + } + + public function testCanResolveComponentNameBeforeRendering(): void + { + $calledWith = null; + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () use (&$calledWith) { + Inertia::transformComponentUsing(static function (string $name) use (&$calledWith): string { + $calledWith = $name; + + return "{$name}/Page"; + }); + + return Inertia::render('Fixtures/Example'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'Fixtures/Example/Page', + ]); + $this->assertSame('Fixtures/Example', $calledWith); + } + + public function testResolvedComponentNameIsUsedForPageExistenceChecks(): void + { + $calledWith = null; + + config()->set('inertia.pages.ensure_pages_exist', true); + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () use (&$calledWith) { + Inertia::transformComponentUsing(static function (string $name) use (&$calledWith): string { + $calledWith = $name; + + return "{$name}/Page"; + }); + + return Inertia::render('Fixtures/Example'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $this->assertSame('Fixtures/Example', $calledWith); + } + + public function testRenderAcceptsBackedEnum(): void + { + $response = (new ResponseFactory)->render(StringBackedEnum::UsersIndex); + $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response); + + /** @phpstan-ignore-next-line */ + $getComponent = fn () => $this->component; + $this->assertSame('UsersPage/Index', $getComponent->call($response)); + } + + public function testRenderAcceptsUnitEnum(): void + { + $response = (new ResponseFactory)->render(UnitEnum::Index); + $this->assertInstanceOf(\Hypervel\Inertia\Response::class, $response); + + /** @phpstan-ignore-next-line */ + $getComponent = fn () => $this->component; + $this->assertSame('Index', $getComponent->call($response)); + } + + public function testRenderThrowsForNonStringBackedEnum(): void + { + $factory = new ResponseFactory; + $this->expectException(InvalidArgumentException::class); + $factory->render(IntBackedEnum::Zero); + } + + public function testShareOnceSharesAOnceProp(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::shareOnce('settings', fn () => ['theme' => 'dark']); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'settings' => ['theme' => 'dark'], + ], + 'onceProps' => [ + 'settings' => [ + 'prop' => 'settings', + 'expiresAt' => null, + ], + ], + ]); + } + + public function testShareOnceIsChainable(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $prop = Inertia::shareOnce('settings', fn () => ['theme' => 'dark']) + ->as('app-settings') + ->until(60); + + $this->assertInstanceOf(OnceProp::class, $prop); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $data = $response->json(); + + $this->assertArrayHasKey('onceProps', $data); + $this->assertArrayHasKey('app-settings', $data['onceProps']); + $this->assertEquals('settings', $data['onceProps']['app-settings']['prop']); + $this->assertNotNull($data['onceProps']['app-settings']['expiresAt']); + } + + public function testForcefullyRefreshingAOncePropIncludesItInOnceProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit', [ + 'settings' => Inertia::once(fn () => ['theme' => 'dark'])->fresh(), + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'settings' => ['theme' => 'dark'], + ], + 'onceProps' => [ + 'settings' => ['prop' => 'settings', 'expiresAt' => null], + ], + ]); + } + + public function testOncePropIsIncludedInOncePropsByDefault(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit', [ + 'settings' => Inertia::once(fn () => ['theme' => 'dark']), + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'settings' => ['theme' => 'dark'], + ], + 'onceProps' => [ + 'settings' => [ + 'prop' => 'settings', + 'expiresAt' => null, + ], + ], + ]); + } + + public function testFlashDataIsFlashedToSessionOnRedirect(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->post('/flash-test', function () { + return Inertia::flash(['message' => 'Success!'])->back(); + }); + + $response = $this->post('/flash-test', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertRedirect(); + $this->assertEquals(['message' => 'Success!'], session('inertia.flash_data')); + } + + public function testRenderWithFlashIncludesFlashInPage(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->post('/flash-test', function () { + return Inertia::flash('type', 'success') + ->render('User/Edit', ['user' => 'Jonathan']) + ->flash(['message' => 'User updated!']); + }); + + $response = $this->post('/flash-test', [], [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'user' => 'Jonathan', + ], + 'flash' => [ + 'message' => 'User updated!', + 'type' => 'success', + ], + ]); + + // Flash data should not persist in session after being included in response + $this->assertNull(session('inertia.flash_data')); + } + + public function testRenderWithoutFlashDoesNotIncludeFlashKey(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/no-flash', function () { + return Inertia::render('User/Edit', ['user' => 'Jonathan']); + }); + + $response = $this->get('/no-flash', [ + 'X-Inertia' => 'true', + ]); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + ]); + $response->assertJsonMissing(['flash']); + } + + public function testMultipleFlashCallsAreMerged(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->post('/create', function () { + Inertia::flash('foo', 'value1'); + Inertia::flash('bar', 'value2'); + + return Inertia::render('User/Show'); + }); + + $response = $this->post('/create', [], ['X-Inertia' => 'true']); + + $response->assertJson([ + 'flash' => [ + 'foo' => 'value1', + 'bar' => 'value2', + ], + ]); + } + + public function testSharedPropsTrackingCanBeDisabled(): void + { + config()->set('inertia.expose_shared_prop_keys', false); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('app_name', 'My App'); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $data = $response->json(); + $this->assertArrayNotHasKey('sharedProps', $data); + $this->assertSame('My App', $data['props']['app_name']); + } + + public function testSharedPropsMetadataIncludesKeysFromMiddlewareShare(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + return Inertia::render('User/Edit', [ + 'user' => ['name' => 'Jonathan'], + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'component' => 'User/Edit', + 'props' => [ + 'user' => ['name' => 'Jonathan'], + ], + 'sharedProps' => ['errors'], + ]); + } + + public function testSharedPropsMetadataIncludesKeysFromInertiaShare(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('app_name', 'My App'); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'sharedProps' => ['errors', 'app_name'], + ]); + } + + public function testSharedPropsMetadataIncludesDotNotationKeysAsTopLevel(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth.user', ['name' => 'Jonathan']); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'sharedProps' => ['errors', 'auth'], + ]); + } + + public function testSharedPropsMetadataIncludesKeysFromShareOnce(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::shareOnce('permissions', fn () => ['admin' => true]); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'props' => [ + 'permissions' => ['admin' => true], + ], + 'sharedProps' => ['errors', 'permissions'], + ]); + } + + public function testSharedPropsMetadataIncludesKeysFromProvidesInertiaProperties(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share(new ExampleInertiaPropsProvider([ + 'app_name' => 'My App', + 'locale' => 'en', + ])); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'props' => [ + 'app_name' => 'My App', + 'locale' => 'en', + ], + 'sharedProps' => ['errors', 'app_name', 'locale'], + ]); + } + + public function testSharedPropsMetadataIncludesPageSpecificOverrideKeys(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('auth', ['user' => null]); + + return Inertia::render('User/Edit', [ + 'auth' => ['user' => ['name' => 'Jonathan']], + ]); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'props' => [ + 'auth' => ['user' => ['name' => 'Jonathan']], + ], + 'sharedProps' => ['errors', 'auth'], + ]); + } + + public function testSharedPropsMetadataWithMultipleShareCalls(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share('app_name', 'My App'); + Inertia::share('locale', 'en'); + Inertia::shareOnce('permissions', fn () => ['admin' => true]); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'sharedProps' => ['errors', 'app_name', 'locale', 'permissions'], + ]); + } + + public function testSharedPropsMetadataWithArrayShare(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::share([ + 'flash' => fn () => ['message' => 'Hello'], + 'auth' => fn () => ['user' => ['name' => 'Jonathan']], + ]); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', ['X-Inertia' => 'true']); + + $response->assertSuccessful(); + $response->assertJson([ + 'sharedProps' => ['errors', 'flash', 'auth'], + ]); + } + + public function testSharedPropsMetadataIncludesAlreadyLoadedOnceProps(): void + { + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + Inertia::shareOnce('permissions', fn () => ['admin' => true]); + + return Inertia::render('User/Edit'); + }); + + $response = $this->withoutExceptionHandling()->get('/', [ + 'X-Inertia' => 'true', + 'X-Inertia-Except-Once-Props' => 'permissions', + ]); + + $response->assertSuccessful(); + $data = $response->json(); + + // The once-prop value should be excluded from props since the client already has it + $this->assertArrayNotHasKey('permissions', $data['props']); + + // But its key should still appear in the sharedProps metadata + $this->assertContains('permissions', $data['sharedProps']); + + // And its onceProps metadata should also be preserved + $this->assertArrayHasKey('permissions', $data['onceProps']); + } + + public function testWithoutSsrRegistersPathsWithGateway(): void + { + Inertia::withoutSsr(['admin/*', 'nova/*']); + + $this->assertContains('admin/*', app(HttpGateway::class)->getExcludedPaths()); + $this->assertContains('nova/*', app(HttpGateway::class)->getExcludedPaths()); + } + + public function testWithoutSsrAcceptsString(): void + { + Inertia::withoutSsr('admin/*'); + + $this->assertContains('admin/*', app(HttpGateway::class)->getExcludedPaths()); + } +} From c80a5605db14543b18bfe72fb5fa76c95c2d42d7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:19 +0000 Subject: [PATCH 68/83] Port ResponseTest --- tests/Inertia/ResponseTest.php | 1889 ++++++++++++++++++++++++++++++++ 1 file changed, 1889 insertions(+) create mode 100644 tests/Inertia/ResponseTest.php diff --git a/tests/Inertia/ResponseTest.php b/tests/Inertia/ResponseTest.php new file mode 100644 index 000000000..661ae1049 --- /dev/null +++ b/tests/Inertia/ResponseTest.php @@ -0,0 +1,1889 @@ +macro('foo', function () { + return 'bar'; + }); + + /* @phpstan-ignore-next-line */ + $this->assertEquals('bar', $response->foo()); + } + + public function testServerResponse(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response('User/Edit', [], ['user' => $user], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithDeferredProp(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new DeferProp(function () { + return 'bar'; + }, 'default'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'default' => ['foo'], + ], $page['deferredProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithDeferredPropAndMultipleGroups(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new DeferProp(function () { + return 'foo value'; + }, 'default'), + 'bar' => new DeferProp(function () { + return 'bar value'; + }, 'default'), + 'baz' => new DeferProp(function () { + return 'baz value'; + }, 'custom'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'default' => ['foo', 'bar'], + 'custom' => ['baz'], + ], $page['deferredProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + #[DataProvider('resetUsersProp')] + public function testServerResponseWithScrollProps(bool $resetUsersProp): void + { + $request = Request::create('/user/123', 'GET'); + + if ($resetUsersProp) { + $request->headers->add(['X-Inertia-Reset' => 'users']); + } + + $response = new Response( + 'User/Index', + [], + [ + 'users' => new ScrollProp(['data' => [['id' => 1]]], 'data', new class implements ProvidesScrollMetadata { + public function getPageName(): string + { + return 'page'; + } + + public function getPreviousPage(): ?int + { + return null; + } + + public function getNextPage(): int + { + return 2; + } + + public function getCurrentPage(): int + { + return 1; + } + }), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Index', $page['component']); + $this->assertSame(['data' => [['id' => 1]]], $page['props']['users']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'users' => [ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + 'reset' => $resetUsersProp, + ], + ], $page['scrollProps']); + } + + /** + * @return array + */ + public static function resetUsersProp(): array + { + return [ + 'no reset' => [false], + 'with reset' => [true], + ]; + } + + public function testServerResponseWithMergeProps(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new MergeProp('foo value'), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'foo', + 'bar', + ], $page['mergeProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithMergePropsThatShouldPrepend(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new MergeProp('foo value'))->prepend(), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame(['bar'], $page['mergeProps']); + $this->assertSame(['foo'], $page['prependProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithMergePropsThatHasNestedPathsToAppendAndPrepend(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new MergeProp(['data' => [['id' => 1], ['id' => 2]]]))->append('data'), + 'bar' => (new MergeProp(['data' => ['items' => [['uuid' => 1], ['uuid' => 2]]]]))->prepend('data.items'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame(['foo.data'], $page['mergeProps']); + $this->assertSame(['bar.data.items'], $page['prependProps']); + $this->assertArrayNotHasKey('matchPropsOn', $page); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithMergePropsThatHasNestedPathsToAppendAndPrependWithMatchOnStrategies(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new MergeProp(['data' => [['id' => 1], ['id' => 2]]]))->append('data', 'id'), + 'bar' => (new MergeProp(['data' => ['items' => [['uuid' => 1], ['uuid' => 2]]]]))->prepend('data.items', 'uuid'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame(['foo.data'], $page['mergeProps']); + $this->assertSame(['bar.data.items'], $page['prependProps']); + $this->assertSame(['foo.data.id', 'bar.data.items.uuid'], $page['matchPropsOn']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithDeepMergeProps(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new MergeProp('foo value'))->deepMerge(), + 'bar' => (new MergeProp('bar value'))->deepMerge(), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'foo', + 'bar', + ], $page['deepMergeProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithMatchOnProps(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new MergeProp('foo value'))->matchOn('foo-key')->deepMerge(), + 'bar' => (new MergeProp('bar value'))->matchOn('bar-key')->deepMerge(), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'foo', + 'bar', + ], $page['deepMergeProps']); + + $this->assertSame([ + 'foo.foo-key', + 'bar.bar-key', + ], $page['matchPropsOn']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithDeferAndMergeProps(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new DeferProp(function () { + return 'foo value'; + }, 'default'))->merge(), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'default' => ['foo'], + ], $page['deferredProps']); + $this->assertSame([ + 'foo', + 'bar', + ], $page['mergeProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testServerResponseWithDeferAndDeepMergeProps(): void + { + $request = Request::create('/user/123', 'GET'); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => (new DeferProp(function () { + return 'foo value'; + }, 'default'))->deepMerge(), + 'bar' => (new MergeProp('bar value'))->deepMerge(), + ], + 'app', + '123' + ); + $response = $response->toResponse($request); + /** @var BaseResponse $response */ + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertSame([ + 'default' => ['foo'], + ], $page['deferredProps']); + $this->assertSame([ + 'foo', + 'bar', + ], $page['deepMergeProps']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame('
', $view->render()); + } + + public function testExcludeMergePropsFromPartialOnlyResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'user']); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new MergeProp('foo value'), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $props = get_object_vars($page->props); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('Jonathan', $props['user']->name); + $this->assertArrayNotHasKey('foo', $props); + $this->assertArrayNotHasKey('bar', $props); + $this->assertFalse(isset($page->mergeProps)); + } + + public function testExcludeMergePropsFromPartialExceptResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Except' => 'foo']); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new MergeProp('foo value'), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $props = get_object_vars($page->props); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('Jonathan', $props['user']->name); + $this->assertArrayNotHasKey('foo', $props); + $this->assertArrayHasKey('bar', $props); + $this->assertSame(['bar'], $page->mergeProps); + } + + public function testExcludeMergePropsWhenPassedInResetHeader(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'foo']); + $request->headers->add(['X-Inertia-Reset' => 'foo']); + + $user = ['name' => 'Jonathan']; + $response = new Response( + 'User/Edit', + [], + [ + 'user' => $user, + 'foo' => new MergeProp('foo value'), + 'bar' => new MergeProp('bar value'), + ], + 'app', + '123' + ); + + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $props = get_object_vars($page->props); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame($props['foo'], 'foo value'); + $this->assertArrayNotHasKey('bar', $props); + $this->assertFalse(isset($page->mergeProps)); + } + + public function testXhrResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $user = (object) ['name' => 'Jonathan']; + $response = new Response('User/Edit', [], ['user' => $user], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertSame('Jonathan', $page->props->user->name); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testXhrResponseWithDeferredPropsIncludesDeferredMetadata(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + 'results' => new DeferProp(fn () => ['data' => ['item1', 'item2']], 'default'), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertSame('Jonathan', $page->props->user->name); + $this->assertFalse(property_exists($page->props, 'results')); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['default' => ['results']], $page->deferredProps); + } + + public function testResourceResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $resource = new FakeResource(['name' => 'Jonathan']); + + $response = new Response('User/Edit', [], ['user' => $resource], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertSame('Jonathan', $page->props->user->name); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testOptionalCallableResourceResponse(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Index', [], [ + 'users' => fn () => [['name' => 'Jonathan']], + 'organizations' => fn () => [['name' => 'Inertia']], + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Index', $page->component); + $this->assertSame('/users', $page->url); + $this->assertSame('123', $page->version); + tap($page->props->users, function ($users) { + $this->assertSame(json_encode([['name' => 'Jonathan']]), json_encode($users)); + }); + tap($page->props->organizations, function ($organizations) { + $this->assertSame(json_encode([['name' => 'Inertia']]), json_encode($organizations)); + }); + } + + public function testOptionalCallableResourcePartialResponse(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Data' => 'users']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Index']); + + $response = new Response('User/Index', [], [ + 'users' => fn () => [['name' => 'Jonathan']], + 'organizations' => fn () => [['name' => 'Inertia']], + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Index', $page->component); + $this->assertSame('/users', $page->url); + $this->assertSame('123', $page->version); + $this->assertFalse(property_exists($page->props, 'organizations')); + tap($page->props->users, function ($users) { + $this->assertSame(json_encode([['name' => 'Jonathan']]), json_encode($users)); + }); + } + + public function testOptionalResourceResponse(): void + { + $request = Request::create('/users', 'GET', ['page' => 1]); + $request->headers->add(['X-Inertia' => 'true']); + + $users = Collection::make([ + new Fluent(['name' => 'Jonathan']), + new Fluent(['name' => 'Taylor']), + new Fluent(['name' => 'Jeffrey']), + ]); + + $callable = static function () use ($users) { + $page = new LengthAwarePaginator($users->take(2), $users->count(), 2); + + return new class($page) extends ResourceCollection {}; + }; + + $response = new Response('User/Index', [], ['users' => $callable], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $expected = [ + 'data' => $users->take(2), + 'links' => [ + 'first' => '/?page=1', + 'last' => '/?page=2', + 'prev' => null, + 'next' => '/?page=2', + ], + 'meta' => [ + 'current_page' => 1, + 'from' => 1, + 'last_page' => 2, + 'path' => '/', + 'per_page' => 2, + 'to' => 2, + 'total' => 3, + ], + ]; + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Index', $page->component); + $this->assertSame('/users?page=1', $page->url); + $this->assertSame('123', $page->version); + tap($page->props->users, function ($users) use ($expected) { + $this->assertSame(json_encode($expected['data']), json_encode($users->data)); + $this->assertSame(json_encode($expected['links']), json_encode($users->links)); + $this->assertSame('/', $users->meta->path); + }); + } + + public function testNestedOptionalResourceResponse(): void + { + $request = Request::create('/users', 'GET', ['page' => 1]); + $request->headers->add(['X-Inertia' => 'true']); + + $users = Collection::make([ + new Fluent(['name' => 'Jonathan']), + new Fluent(['name' => 'Taylor']), + new Fluent(['name' => 'Jeffrey']), + ]); + + $callable = static function () use ($users) { + $page = new LengthAwarePaginator($users->take(2), $users->count(), 2); + + // nested array with ResourceCollection to resolve + return [ + 'users' => new class($page) extends ResourceCollection {}, + ]; + }; + + $response = new Response('User/Index', [], ['something' => $callable], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $expected = [ + 'users' => [ + 'data' => $users->take(2), + 'links' => [ + 'first' => '/?page=1', + 'last' => '/?page=2', + 'prev' => null, + 'next' => '/?page=2', + ], + 'meta' => [ + 'current_page' => 1, + 'from' => 1, + 'last_page' => 2, + 'path' => '/', + 'per_page' => 2, + 'to' => 2, + 'total' => 3, + ], + ], + ]; + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Index', $page->component); + $this->assertSame('/users?page=1', $page->url); + $this->assertSame('123', $page->version); + tap($page->props->something->users, function ($users) use ($expected) { + $this->assertSame(json_encode($expected['users']['data']), json_encode($users->data)); + $this->assertSame(json_encode($expected['users']['links']), json_encode($users->links)); + $this->assertSame('/', $users->meta->path); + }); + } + + public function testArrayablePropResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $resource = FakeResource::make(['name' => 'Jonathan']); + + $response = new Response('User/Edit', [], ['user' => $resource], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertSame('Jonathan', $page->props->user->name); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testPromisePropsAreResolved(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $user = (object) ['name' => 'Jonathan']; + + $promise = Mockery::mock('GuzzleHttp\Promise\PromiseInterface') + ->shouldReceive('wait') + ->andReturn($user) + ->getMock(); + + $response = new Response('User/Edit', [], ['user' => $promise], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertSame('Jonathan', $page->props->user->name); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testXhrPartialResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'partial']); + + $user = (object) ['name' => 'Jonathan']; + $response = new Response('User/Edit', [], ['user' => $user, 'partial' => 'partial-data'], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $props = get_object_vars($page->props); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertFalse(isset($props['user'])); + $this->assertCount(1, $props); + $this->assertSame('partial-data', $page->props->partial); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testExcludePropsFromPartialResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Except' => 'user']); + + $user = (object) ['name' => 'Jonathan']; + $response = new Response('User/Edit', [], ['user' => $user, 'partial' => 'partial-data'], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $props = get_object_vars($page->props); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertSame('User/Edit', $page->component); + $this->assertFalse(isset($props['user'])); + $this->assertCount(1, $props); + $this->assertSame('partial-data', $page->props->partial); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + } + + public function testNestedClosuresAreResolved(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'auth' => [ + 'user' => fn () => ['name' => 'Jonathan'], + 'token' => 'value', + ], + ], 'app', '123'); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['auth']['user']['name']); + $this->assertSame('value', $page['props']['auth']['token']); + } + + public function testDoubleNestedClosuresAreResolved(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'auth' => fn () => [ + 'user' => fn () => ['name' => 'Jonathan'], + 'token' => 'value', + ], + ], 'app', '123'); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['auth']['user']['name']); + $this->assertSame('value', $page['props']['auth']['token']); + } + + public function testNestedOptionalPropInsideClosureIsExcluded(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'auth' => fn () => [ + 'user' => ['name' => 'Jonathan'], + 'pending' => Inertia::optional(fn () => 'secret'), + ], + ], 'app', '123'); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['auth']['user']['name']); + $this->assertArrayNotHasKey('pending', $page['props']['auth']); + } + + public function testNestedPartialProps(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'auth.user,auth.refresh_token']); + + $props = [ + 'auth' => [ + 'user' => new OptionalProp(function () { + return [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ]; + }), + 'refresh_token' => 'value', + 'token' => 'value', + ], + 'shared' => [ + 'flash' => 'value', + ], + ]; + + $response = new Response('User/Edit', [], $props); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertFalse(isset($page->props->shared)); + $this->assertFalse(isset($page->props->auth->token)); + $this->assertSame('Jonathan Reinink', $page->props->auth->user->name); + $this->assertSame('jonathan@example.com', $page->props->auth->user->email); + $this->assertSame('value', $page->props->auth->refresh_token); + } + + public function testExcludeNestedPropsFromPartialResponse(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'auth']); + $request->headers->add(['X-Inertia-Partial-Except' => 'auth.user']); + + $props = [ + 'auth' => [ + 'user' => new OptionalProp(function () { + return [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ]; + }), + 'refresh_token' => 'value', + ], + 'shared' => [ + 'flash' => 'value', + ], + ]; + + $response = new Response('User/Edit', [], $props); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertFalse(isset($page->props->auth->user)); + $this->assertFalse(isset($page->props->shared)); + $this->assertSame('value', $page->props->auth->refresh_token); + } + + public function testOptionalPropsAreNotIncludedByDefault(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $optionalProp = new OptionalProp(function () { + return 'An optional value'; + }); + + $response = new Response('Users', [], ['users' => [], 'optional' => $optionalProp], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame([], $page->props->users); + $this->assertFalse(property_exists($page->props, 'optional')); + } + + public function testOptionalPropsAreIncludedInPartialReload(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'Users']); + $request->headers->add(['X-Inertia-Partial-Data' => 'optional']); + + $optionalProp = new OptionalProp(function () { + return 'An optional value'; + }); + + $response = new Response('Users', [], ['users' => [], 'optional' => $optionalProp], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertFalse(property_exists($page->props, 'users')); + $this->assertSame('An optional value', $page->props->optional); + } + + public function testDeferArrayablePropsAreResolvedInPartialReload(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'Users']); + $request->headers->add(['X-Inertia-Partial-Data' => 'defer']); + + $deferProp = new DeferProp(function () { + return new class implements Arrayable { + public function toArray(): array + { + return ['foo' => 'bar']; + } + }; + }); + + $response = new Response('Users', [], ['users' => [], 'defer' => $deferProp], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertFalse(property_exists($page->props, 'users')); + $this->assertEquals((object) ['foo' => 'bar'], $page->props->defer); + } + + public function testAlwaysPropsAreIncludedOnPartialReload(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'data']); + + $props = [ + 'user' => new OptionalProp(function () { + return [ + 'name' => 'Jonathan Reinink', + 'email' => 'jonathan@example.com', + ]; + }), + 'data' => [ + 'name' => 'Taylor Otwell', + ], + 'errors' => new AlwaysProp(function () { + return [ + 'name' => 'The email field is required.', + ]; + }), + ]; + + $response = new Response('User/Edit', [], $props, 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('The email field is required.', $page->props->errors->name); + $this->assertSame('Taylor Otwell', $page->props->data->name); + $this->assertFalse(isset($page->props->user)); + } + + public function testStringFunctionNamesAreNotInvokedAsCallables(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'always' => new AlwaysProp('date'), + 'merge' => new MergeProp('trim'), + ], 'app', '123'); + + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getOriginalContent()->getData()['page']; + + $this->assertSame('date', $page['props']['always']); + $this->assertSame('trim', $page['props']['merge']); + } + + public function testInertiaResponsableObjects(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'foo' => 'bar', + new class implements ProvidesInertiaProperties { + /** + * @return Collection + */ + public function toInertiaProperties(RenderContext $context): iterable + { + return collect([ + 'baz' => 'qux', + ]); + } + }, + 'quux' => 'corge', + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame('qux', $page['props']['baz']); + $this->assertSame('corge', $page['props']['quux']); + } + + public function testInertiaResponseTypeProp(): void + { + $request = Request::create('/user/123', 'GET'); + + Inertia::share('items', ['foo']); + Inertia::share('deep.foo.bar', ['foo']); + + $response = new Response('User/Edit', [], [ + 'items' => new MergeWithSharedProp(['bar']), + 'deep' => [ + 'foo' => [ + 'bar' => new MergeWithSharedProp(['baz']), + ], + ], + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame(['foo', 'bar'], $page['props']['items']); + $this->assertSame(['foo', 'baz'], $page['props']['deep']['foo']['bar']); + } + + public function testTopLevelDotPropsGetUnpacked(): void + { + $props = [ + 'auth' => [ + 'user' => [ + 'name' => 'Jonathan Reinink', + ], + ], + 'auth.user.can' => [ + 'do.stuff' => true, + ], + 'product' => ['name' => 'My example product'], + ]; + + $request = Request::create('/products/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Edit', [], $props, 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(true); + + $user = $page['props']['auth']['user']; + $this->assertSame('Jonathan Reinink', $user['name']); + $this->assertTrue($user['can']['do.stuff']); + $this->assertFalse(array_key_exists('auth.user.can', $page['props'])); + } + + public function testNestedDotPropsDoNotGetUnpacked(): void + { + $props = [ + 'auth' => [ + 'user.can' => [ + 'do.stuff' => true, + ], + 'user' => [ + 'name' => 'Jonathan Reinink', + ], + ], + 'product' => ['name' => 'My example product'], + ]; + + $request = Request::create('/products/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Edit', [], $props, 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(true); + + $auth = $page['props']['auth']; + $this->assertSame('Jonathan Reinink', $auth['user']['name']); + $this->assertTrue($auth['user.can']['do.stuff']); + $this->assertFalse(array_key_exists('can', $auth)); + } + + public function testPropsCanBeAddedUsingTheWithMethod(): void + { + $request = Request::create('/user/123', 'GET'); + $response = new Response('User/Edit', [], [], 'app', '123'); + + $response->with(['foo' => 'bar', 'baz' => 'qux']) + ->with(['quux' => 'corge']) + ->with(new class implements ProvidesInertiaProperties { + /** + * @return Collection + */ + public function toInertiaProperties(RenderContext $context): iterable + { + return collect(['grault' => 'garply']); + } + }); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame('qux', $page['props']['baz']); + $this->assertSame('corge', $page['props']['quux']); + } + + public function testOncePropsAreAlwaysResolvedOnInitialPageLoad(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertArrayNotHasKey('clearHistory', $page); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertSame(['foo' => ['prop' => 'foo', 'expiresAt' => null]], $page['onceProps']); + $this->assertSame('
', $view->render()); + } + + public function testFreshOncePropsAreIncludedOnInitialPageLoad(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')->fresh()], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame('/user/123', $page['url']); + $this->assertSame('123', $page['version']); + $this->assertArrayHasKey('onceProps', $page); + $this->assertSame(['foo' => ['prop' => 'foo', 'expiresAt' => null]], $page['onceProps']); + } + + public function testOncePropsAreResolvedWithACustomKeyAndTtlValue(): void + { + $this->freezeSecond(); + + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Edit', [], [ + 'foo' => Inertia::once(fn () => 'bar')->as('baz')->until(now()->addMinute()), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['baz' => (object) ['prop' => 'foo', 'expiresAt' => now()->addMinute()->getTimestampMs()]], $page->onceProps); + } + + public function testOncePropsAreNotResolvedOnSubsequentRequestsWhenTheyAreInTheOncePropsHeader(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo']); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertArrayNotHasKey('foo', (array) $page->props); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['foo' => (object) ['prop' => 'foo', 'expiresAt' => null]], $page->onceProps); + } + + public function testOncePropsAreResolvedOnSubsequentRequestsWhenTheOncePropsHeaderIsMissing(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['foo' => (object) ['prop' => 'foo', 'expiresAt' => null]], $page->onceProps); + } + + public function testOncePropsAreResolvedOnSubsequentRequestsWhenTheyAreNotInTheOncePropsHeader(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'baz']); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['foo' => (object) ['prop' => 'foo', 'expiresAt' => null]], $page->onceProps); + } + + public function testOncePropsAreResolvedOnPartialRequestsWithoutOnlyOrExcept(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'foo']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo']); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) ['foo' => (object) ['prop' => 'foo', 'expiresAt' => null]], $page->onceProps); + } + + public function testOncePropsAreResolvedOnPartialRequestsWhenIncludedInOnlyHeaders(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'foo']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo']); + + $response = new Response('User/Edit', [], [ + 'foo' => Inertia::once(fn () => 'bar'), + 'baz' => Inertia::once(fn () => 'qux'), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertFalse(isset($page->props->baz)); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) [ + 'foo' => (object) ['prop' => 'foo', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testOncePropsAreNotResolvedOnPartialRequestsWhenExcludedInExceptHeaders(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Except' => 'foo']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo']); + + $response = new Response('User/Edit', [], [ + 'foo' => Inertia::once(fn () => 'bar'), + 'baz' => Inertia::once(fn () => 'qux'), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertFalse(isset($page->props->foo)); + $this->assertSame('qux', $page->props->baz); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) [ + 'baz' => (object) ['prop' => 'baz', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testFreshPropsAreResolvedEvenWhenInExceptOncePropsHeader(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo']); + + $response = new Response('User/Edit', [], ['foo' => Inertia::once(fn () => 'bar')->fresh()], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) [ + 'foo' => (object) ['prop' => 'foo', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testFreshPropsAreNotExcludedWhileOncePropsAreExcluded(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'foo,baz']); + + $response = new Response('User/Edit', [], [ + 'foo' => Inertia::once(fn () => 'bar')->fresh(), + 'baz' => Inertia::once(fn () => 'qux'), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('bar', $page->props->foo); + $this->assertFalse(isset($page->props->baz)); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) [ + 'foo' => (object) ['prop' => 'foo', 'expiresAt' => null], + 'baz' => (object) ['prop' => 'baz', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testDeferPropsThatAreOnceAndAlreadyLoadedAreExcluded(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'defer']); + + $response = new Response('User/Edit', [], [ + 'defer' => Inertia::defer(fn () => 'value')->once(), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertFalse(isset($page->props->defer)); + $this->assertFalse(isset($page->deferredProps)); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertEquals((object) [ + 'defer' => (object) ['prop' => 'defer', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testDeferPropsThatAreOnceAndAlreadyLoadedNotExcludedWhenExplicitlyRequested(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'defer']); + $request->headers->add(['X-Inertia-Except-Once-Props' => 'defer']); + + $response = new Response('User/Edit', [], [ + 'defer' => Inertia::defer(fn () => 'value')->once(), + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertInstanceOf(JsonResponse::class, $response); + + $this->assertSame('User/Edit', $page->component); + $this->assertSame('value', $page->props->defer); + $this->assertSame('/user/123', $page->url); + $this->assertSame('123', $page->version); + $this->assertFalse(isset($page->deferredProps)); + $this->assertEquals((object) [ + 'defer' => (object) ['prop' => 'defer', 'expiresAt' => null], + ], $page->onceProps); + } + + public function testResponsableWithInvalidKey(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $resource = new FakeResource(["\x00*\x00_invalid_key" => 'for object']); + + $response = new Response('User/Edit', [], ['resource' => $resource], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(true); + + $this->assertSame( + ["\x00*\x00_invalid_key" => 'for object'], + $page['props']['resource'] + ); + } + + public function testThePageUrlIsPrefixedWithTheProxyPrefix(): void + { + Request::setTrustedProxies(['1.2.3.4'], Request::HEADER_X_FORWARDED_PREFIX); + + $request = Request::create('/user/123', 'GET'); + $request->server->set('REMOTE_ADDR', '1.2.3.4'); + $request->headers->set('X_FORWARDED_PREFIX', '/sub/directory'); + + $user = ['name' => 'Jonathan']; + $response = new Response('User/Edit', [], ['user' => $user], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertInstanceOf(BaseResponse::class, $response); + $this->assertInstanceOf(View::class, $view); + + $this->assertSame('/sub/directory/user/123', $page['url']); + } + + public function testThePageUrlDoesntDoubleUp(): void + { + $request = Request::create('/subpath/product/123', 'GET', [], [], [], [ + 'SCRIPT_FILENAME' => '/project/public/index.php', + 'SCRIPT_NAME' => '/subpath/index.php', + ]); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('Product/Show', [], []); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('/subpath/product/123', $page->url); + } + + public function testTrailingSlashesInAUrlArePreserved(): void + { + $request = Request::create('/users/', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Index', [], []); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('/users/', $page->url); + } + + public function testTrailingSlashesInAUrlWithQueryParametersArePreserved(): void + { + $request = Request::create('/users/?page=1&sort=name', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Index', [], []); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('/users/?page=1&sort=name', $page->url); + } + + public function testAUrlWithoutTrailingSlashIsResolvedCorrectly(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Index', [], []); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('/users', $page->url); + } + + public function testAUrlWithoutTrailingSlashAndQueryParametersIsResolvedCorrectly(): void + { + $request = Request::create('/users?page=1&sort=name', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + + $response = new Response('User/Index', [], []); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertSame('/users?page=1&sort=name', $page->url); + } + + public function testDeferredPropsFromProvidesInertiaPropertiesAreIncludedInDeferredPropsMetadata(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => new DeferProp(fn () => 'bar', 'default'), + ]; + } + }, + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertArrayNotHasKey('foo', $page['props']); + $this->assertSame([ + 'default' => ['foo'], + ], $page['deferredProps']); + } + + public function testDeferredPropsFromProvidesInertiaPropertiesWithMultipleGroups(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => new DeferProp(fn () => 'foo value', 'default'), + 'bar' => new DeferProp(fn () => 'bar value', 'custom'), + ]; + } + }, + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertArrayNotHasKey('foo', $page['props']); + $this->assertArrayNotHasKey('bar', $page['props']); + $this->assertSame([ + 'default' => ['foo'], + 'custom' => ['bar'], + ], $page['deferredProps']); + } + + public function testDeferredPropsFromProvidesInertiaPropertiesCanBeLoadedViaPartialRequest(): void + { + $request = Request::create('/user/123', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'User/Edit']); + $request->headers->add(['X-Inertia-Partial-Data' => 'foo']); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => new DeferProp(fn () => 'bar', 'default'), + ]; + } + }, + ], 'app', '123'); + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(true); + + $this->assertSame('bar', $page['props']['foo']); + $this->assertArrayNotHasKey('user', $page['props']); + } + + public function testMergePropsFromProvidesInertiaPropertiesAreIncludedInMergePropsMetadata(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => new MergeProp('foo value'), + ]; + } + }, + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('foo value', $page['props']['foo']); + $this->assertSame(['foo'], $page['mergeProps']); + } + + public function testOncePropsFromProvidesInertiaPropertiesAreIncludedInOncePropsMetadata(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => Inertia::once(fn () => 'bar'), + ]; + } + }, + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertSame('bar', $page['props']['foo']); + $this->assertSame(['foo' => ['prop' => 'foo', 'expiresAt' => null]], $page['onceProps']); + } + + public function testDeferredMergePropsFromProvidesInertiaPropertiesIncludeBothMetadata(): void + { + $request = Request::create('/user/123', 'GET'); + + $response = new Response('User/Edit', [], [ + 'user' => ['name' => 'Jonathan'], + new class implements ProvidesInertiaProperties { + public function toInertiaProperties(RenderContext $context): iterable + { + return [ + 'foo' => (new DeferProp(fn () => 'foo value', 'default'))->merge(), + ]; + } + }, + ], 'app', '123'); + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertSame('Jonathan', $page['props']['user']['name']); + $this->assertArrayNotHasKey('foo', $page['props']); + $this->assertSame([ + 'default' => ['foo'], + ], $page['deferredProps']); + $this->assertSame(['foo'], $page['mergeProps']); + } +} From e654174d5f5367da250b5f525c11267a83abc3fa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:19 +0000 Subject: [PATCH 69/83] Port ScrollMetadataTest with RequestContext-based pagination --- tests/Inertia/ScrollMetadataTest.php | 152 +++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/Inertia/ScrollMetadataTest.php diff --git a/tests/Inertia/ScrollMetadataTest.php b/tests/Inertia/ScrollMetadataTest.php new file mode 100644 index 000000000..533a467d8 --- /dev/null +++ b/tests/Inertia/ScrollMetadataTest.php @@ -0,0 +1,152 @@ +simplePaginate(15); + + if ($wrappedinHttpResource) { + $users = UserResource::collection($users); + } + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?page=2')); + $users = User::query()->simplePaginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 1, + 'nextPage' => 3, + 'currentPage' => 2, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?page=3')); + $users = User::query()->simplePaginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 2, + 'nextPage' => null, + 'currentPage' => 3, + ], ScrollMetadata::fromPaginator($users)->toArray()); + } + + #[DataProvider('wrappedOrUnwrappedProvider')] + public function testExtractMetadataFromLengthAwarePaginator(bool $wrappedinHttpResource): void + { + $users = User::query()->paginate(15); + + if ($wrappedinHttpResource) { + $users = UserResource::collection($users); + } + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?page=2')); + $users = User::query()->paginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 1, + 'nextPage' => 3, + 'currentPage' => 2, + ], ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?page=3')); + $users = User::query()->paginate(15); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => 2, + 'nextPage' => null, + 'currentPage' => 3, + ], ScrollMetadata::fromPaginator($users)->toArray()); + } + + #[DataProvider('wrappedOrUnwrappedProvider')] + public function testExtractMetadataFromCursorPaginator(bool $wrappedinHttpResource): void + { + $users = User::query()->cursorPaginate(15); + + if ($wrappedinHttpResource) { + $users = UserResource::collection($users); + } + + $this->assertEquals([ + 'pageName' => 'cursor', + 'previousPage' => null, + 'nextPage' => $users->nextCursor()?->encode(), + 'currentPage' => 1, + ], $first = ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?cursor=' . $first['nextPage'])); + $users = User::query()->cursorPaginate(15); + + $this->assertEquals([ + 'pageName' => 'cursor', + 'previousPage' => $users->previousCursor()?->encode(), + 'nextPage' => $users->nextCursor()?->encode(), + 'currentPage' => $first['nextPage'], + ], $second = ScrollMetadata::fromPaginator($users)->toArray()); + + RequestContext::set(Request::create('/?cursor=' . $second['nextPage'])); + $users = User::query()->cursorPaginate(15); + + $this->assertEquals([ + 'pageName' => 'cursor', + 'previousPage' => $users->previousCursor()?->encode(), + 'nextPage' => null, + 'currentPage' => $second['nextPage'], + ], ScrollMetadata::fromPaginator($users)->toArray()); + } + + /** + * @return array> + */ + public static function wrappedOrUnwrappedProvider(): array + { + return [ + 'wrapped in http resource' => [true], + 'not wrapped in http resource' => [false], + ]; + } + + public function testThrowsExceptionIfNotAPaginator(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The given value is not a Hypervel paginator instance. Use a custom callback to extract pagination metadata.'); + + ScrollMetadata::fromPaginator(collect()); + } +} From fe43bef09dd2182254f0410aee1c80a7eab38914 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:19 +0000 Subject: [PATCH 70/83] Port ScrollPropTest with RequestContext-based header simulation --- tests/Inertia/ScrollPropTest.php | 261 +++++++++++++++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 tests/Inertia/ScrollPropTest.php diff --git a/tests/Inertia/ScrollPropTest.php b/tests/Inertia/ScrollPropTest.php new file mode 100644 index 000000000..5bed81e40 --- /dev/null +++ b/tests/Inertia/ScrollPropTest.php @@ -0,0 +1,261 @@ +setUpInteractsWithUserModels(); + } + + public function testResolvesMetaData(): void + { + $users = User::query()->paginate(15); + $scrollProp = new ScrollProp($users); + + $metadata = $scrollProp->metadata(); + + $this->assertEquals([ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + ], $metadata); + } + + public function testResolvesCustomMetaData(): void + { + $users = User::query()->paginate(15); + + $customMetaCallback = fn () => new class implements ProvidesScrollMetadata { + public function getPageName(): string + { + return 'usersPage'; + } + + public function getPreviousPage(): int + { + return 10; + } + + public function getNextPage(): int + { + return 12; + } + + public function getCurrentPage(): int + { + return 11; + } + }; + + $scrollProp = new ScrollProp($users, 'data', $customMetaCallback); + + $metadata = $scrollProp->metadata(); + + $this->assertEquals([ + 'pageName' => 'usersPage', + 'previousPage' => 10, + 'nextPage' => 12, + 'currentPage' => 11, + ], $metadata); + } + + public function testCanSetTheMergeIntentBasedOnTheMergeIntentHeader(): void + { + $users = User::query()->paginate(15); + $request = Request::create('/'); + RequestContext::set($request); + + // Test append intent without header + $appendProp = new ScrollProp($users); + $appendProp->configureMergeIntent(); + $this->assertContains('data', $appendProp->appendsAtPaths()); + $this->assertEmpty($appendProp->prependsAtPaths()); + + // Test append intent with header set to 'append' + $request->headers->set(Header::INFINITE_SCROLL_MERGE_INTENT, 'append'); + $appendProp = new ScrollProp($users); + $appendProp->configureMergeIntent(); + $this->assertContains('data', $appendProp->appendsAtPaths()); + $this->assertEmpty($appendProp->prependsAtPaths()); + + // Test prepend intent + $request->headers->set(Header::INFINITE_SCROLL_MERGE_INTENT, 'prepend'); + $prependProp = new ScrollProp($users); + $prependProp->configureMergeIntent(); + $this->assertContains('data', $prependProp->prependsAtPaths()); + $this->assertEmpty($prependProp->appendsAtPaths()); + + // Test prepend intent with custom wrapper + $request->headers->set(Header::INFINITE_SCROLL_MERGE_INTENT, 'prepend'); + $prependProp = new ScrollProp($users, 'items'); + $prependProp->configureMergeIntent(); + $this->assertContains('items', $prependProp->prependsAtPaths()); + $this->assertEmpty($prependProp->appendsAtPaths()); + } + + public function testResolvesMetaDataWithCallableProvider(): void + { + $callableMetadata = function () { + return new class implements ProvidesScrollMetadata { + public function getPageName(): string + { + return 'callablePage'; + } + + public function getPreviousPage(): int + { + return 5; + } + + public function getNextPage(): int + { + return 7; + } + + public function getCurrentPage(): int + { + return 6; + } + }; + }; + + $scrollProp = new ScrollProp([], 'data', $callableMetadata); + + $metadata = $scrollProp->metadata(); + + $this->assertEquals([ + 'pageName' => 'callablePage', + 'previousPage' => 5, + 'nextPage' => 7, + 'currentPage' => 6, + ], $metadata); + } + + public function testScrollPropValueIsResolvedOnlyOnce(): void + { + $callCount = 0; + + $scrollProp = new ScrollProp(function () use (&$callCount) { + ++$callCount; + + return ['item1', 'item2', 'item3']; + }); + + // Call the scroll prop multiple times + $value1 = $scrollProp(); + $value2 = $scrollProp(); + $value3 = $scrollProp(); + + // Verify the callback was only called once + $this->assertEquals(1, $callCount, 'Scroll prop value callback should only be executed once'); + + // Verify all calls return the same result + $this->assertEquals($value1, $value2); + $this->assertEquals($value2, $value3); + $this->assertEquals(['item1', 'item2', 'item3'], $value1); + } + + public function testStringFunctionNamesAreNotInvoked(): void + { + $scrollProp = new ScrollProp('date'); + + $this->assertSame('date', $scrollProp()); + } + + public function testDeferredScrollPropIsExcludedFromInitialRequest(): void + { + $request = Request::create('/users', 'GET'); + + $response = new Response( + 'Users/Index', + [], + [ + 'users' => (new ScrollProp(fn () => User::query()->paginate(15)))->defer(), + ], + 'app', + '123' + ); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertArrayNotHasKey('users', $page['props']); + $this->assertSame(['default' => ['users']], $page['deferredProps']); + $this->assertArrayNotHasKey('scrollProps', $page); + } + + public function testDeferredScrollPropIsResolvedOnPartialRequest(): void + { + $request = Request::create('/users', 'GET'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Partial-Component' => 'Users/Index']); + $request->headers->add(['X-Inertia-Partial-Data' => 'users']); + + $response = new Response( + 'Users/Index', + [], + [ + 'users' => (new ScrollProp(fn () => User::query()->paginate(15)))->defer(), + ], + 'app', + '123' + ); + + /** @var JsonResponse $response */ + $response = $response->toResponse($request); + $page = $response->getData(); + + $this->assertObjectHasProperty('users', $page->props); + $this->assertCount(15, $page->props->users->data); + $this->assertObjectHasProperty('scrollProps', $page); + $this->assertEquals('page', $page->scrollProps->users->pageName); + $this->assertContains('users.data', $page->mergeProps); + } + + public function testDeferredScrollPropCanHaveCustomGroup(): void + { + $request = Request::create('/users', 'GET'); + + $response = new Response( + 'Users/Index', + [], + [ + 'users' => (new ScrollProp(fn () => User::query()->paginate(15)))->defer('custom-group'), + ], + 'app', + '123' + ); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + + $this->assertArrayNotHasKey('users', $page['props']); + $this->assertSame(['custom-group' => ['users']], $page['deferredProps']); + } +} From f1f16a45b34faedf14a78a7f7633c3bb20918e87 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:19 +0000 Subject: [PATCH 71/83] Port ServiceProviderTest as InertiaServiceProviderTest --- tests/Inertia/InertiaServiceProviderTest.php | 84 ++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/Inertia/InertiaServiceProviderTest.php diff --git a/tests/Inertia/InertiaServiceProviderTest.php b/tests/Inertia/InertiaServiceProviderTest.php new file mode 100644 index 000000000..caf26dc45 --- /dev/null +++ b/tests/Inertia/InertiaServiceProviderTest.php @@ -0,0 +1,84 @@ +assertArrayHasKey('inertia', Blade::getCustomDirectives()); + } + + public function testRequestMacroIsRegistered(): void + { + $request = Request::create('/user/123', 'GET'); + + $this->assertFalse($request->inertia()); + + $request->headers->add(['X-Inertia' => 'true']); + + $this->assertTrue($request->inertia()); + } + + public function testRouteMacroIsRegistered(): void + { + $route = Route::inertia('/', 'User/Edit', ['user' => ['name' => 'Jonathan']]); + $routes = Route::getRoutes(); + + $this->assertNotEmpty($routes->getRoutes()); + + $inertiaRoute = collect($routes->getRoutes())->first(fn ($route) => $route->uri === '/'); + + $this->assertEquals($route, $inertiaRoute); + $this->assertEquals(['GET', 'HEAD'], $inertiaRoute->methods); + $this->assertEquals('/', $inertiaRoute->uri); + $this->assertEquals(['uses' => '\Hypervel\Inertia\Controller@__invoke', 'controller' => '\Hypervel\Inertia\Controller'], $inertiaRoute->action); + $this->assertEquals(['component' => 'User/Edit', 'props' => ['user' => ['name' => 'Jonathan']]], $inertiaRoute->defaults); + } + + public function testEnsureGetOnRedirectMiddlewareIsRegisteredGlobally(): void + { + /** @var Kernel $kernel */ + $kernel = $this->app->make(HttpKernelContract::class); + + $this->assertTrue($kernel->hasMiddleware(EnsureGetOnRedirect::class)); + } + + public function testRedirectResponseFromRateLimiterIsConvertedTo303(): void + { + RateLimiter::for('api', fn () => Limit::perMinute(1)->response(fn () => back())); + + // Needed for the web middleware + config(['app.key' => 'base64:' . base64_encode(random_bytes(32))]); + + Route::middleware(['web', ExampleMiddleware::class, 'throttle:api']) + ->delete('/foo', fn () => 'ok'); + + $this + ->from('/bar') + ->delete('/foo', [], ['X-Inertia' => 'true']) + ->assertOk(); + + $this + ->from('/bar') + ->delete('/foo', [], ['X-Inertia' => 'true']) + ->assertRedirect('/bar') + ->assertStatus(303); + } +} From 88817c4a5a62f27a4c1f0ab7e469829a3dfd6cbb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:33 +0000 Subject: [PATCH 72/83] Port SsrRenderFailedTest --- tests/Inertia/SsrRenderFailedTest.php | 82 +++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 tests/Inertia/SsrRenderFailedTest.php diff --git a/tests/Inertia/SsrRenderFailedTest.php b/tests/Inertia/SsrRenderFailedTest.php new file mode 100644 index 000000000..a277efa98 --- /dev/null +++ b/tests/Inertia/SsrRenderFailedTest.php @@ -0,0 +1,82 @@ + 'Dashboard', 'url' => '/dashboard'], + error: 'Test error', + ); + + $this->assertEquals('Dashboard', $event->component()); + } + + public function testItExtractsUrlFromPage(): void + { + $event = new SsrRenderFailed( + page: ['component' => 'Dashboard', 'url' => '/dashboard'], + error: 'Test error', + ); + + $this->assertEquals('/dashboard', $event->url()); + } + + public function testItStoresSourceLocation(): void + { + $event = new SsrRenderFailed( + page: ['component' => 'Dashboard'], + error: 'window is not defined', + sourceLocation: '/path/to/Dashboard.vue:10:5', + ); + + $this->assertEquals('/path/to/Dashboard.vue:10:5', $event->sourceLocation); + } + + public function testItIncludesSourceLocationInToArray(): void + { + $event = new SsrRenderFailed( + page: ['component' => 'Dashboard', 'url' => '/dashboard'], + error: 'window is not defined', + type: SsrErrorType::BrowserApi, + hint: 'Wrap in lifecycle hook', + browserApi: 'window', + sourceLocation: '/path/to/Dashboard.vue:10:5', + ); + + $array = $event->toArray(); + + $this->assertEquals('/path/to/Dashboard.vue:10:5', $array['source_location']); + $this->assertEquals('Dashboard', $array['component']); + $this->assertEquals('/dashboard', $array['url']); + $this->assertEquals('window is not defined', $array['error']); + $this->assertEquals('browser-api', $array['type']); + $this->assertEquals('Wrap in lifecycle hook', $array['hint']); + $this->assertEquals('window', $array['browser_api']); + } + + public function testToArrayExcludesNullValues(): void + { + $event = new SsrRenderFailed( + page: ['component' => 'Dashboard', 'url' => '/dashboard'], + error: 'Something went wrong', + ); + + $array = $event->toArray(); + + $this->assertArrayNotHasKey('hint', $array); + $this->assertArrayNotHasKey('browser_api', $array); + $this->assertArrayNotHasKey('source_location', $array); + } +} From 68d021587b8318594b67d95ff372cd1ebb55d31e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:34 +0000 Subject: [PATCH 73/83] Port CheckSsrTest --- tests/Inertia/Commands/CheckSsrTest.php | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/Inertia/Commands/CheckSsrTest.php diff --git a/tests/Inertia/Commands/CheckSsrTest.php b/tests/Inertia/Commands/CheckSsrTest.php new file mode 100644 index 000000000..9a09f5d7a --- /dev/null +++ b/tests/Inertia/Commands/CheckSsrTest.php @@ -0,0 +1,48 @@ +shouldReceive('isHealthy')->andReturn(true); + $this->app->instance(Gateway::class, $mock); + + $this->artisan('inertia:check-ssr') + ->expectsOutput('Inertia SSR server is running.') + ->assertExitCode(0); + } + + public function testFailureOnUnhealthySsrServer(): void + { + $mock = Mockery::mock(Gateway::class, HasHealthCheck::class); + $mock->shouldReceive('isHealthy')->andReturn(false); + $this->app->instance(Gateway::class, $mock); + + $this->artisan('inertia:check-ssr') + ->expectsOutput('Inertia SSR server is not running.') + ->assertExitCode(1); + } + + public function testFailureOnUnsupportedGateway(): void + { + $this->mock(Gateway::class); + + $this->artisan('inertia:check-ssr') + ->expectsOutput('The SSR gateway does not support health checks.') + ->assertExitCode(1); + } +} From 0431e3c84930986478090ba7bb2d8b51586d7581 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:34 +0000 Subject: [PATCH 74/83] Port StartSsrTest --- tests/Inertia/Commands/StartSsrTest.php | 149 ++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 tests/Inertia/Commands/StartSsrTest.php diff --git a/tests/Inertia/Commands/StartSsrTest.php b/tests/Inertia/Commands/StartSsrTest.php new file mode 100644 index 000000000..cbe1a3712 --- /dev/null +++ b/tests/Inertia/Commands/StartSsrTest.php @@ -0,0 +1,149 @@ + */ + protected ?array $processCommand = null; + + protected function setUp(): void + { + parent::setUp(); + + config()->set('inertia.ssr.enabled', true); + config()->set('inertia.ssr.bundle', __FILE__); + } + + protected function fakeProcess(): void + { + $this->app->bind(Process::class, function ($app, $params) { + $this->processCommand = $params['command']; + + return new Process(['true']); + }); + } + + public function testErrorWhenSsrIsDisabled(): void + { + config()->set('inertia.ssr.enabled', false); + + $this->artisan('inertia:start-ssr') + ->expectsOutput('Inertia SSR is not enabled. Enable it via the `inertia.ssr.enabled` config option.') + ->assertExitCode(1); + } + + public function testErrorWhenConfiguredBundleNotFound(): void + { + config()->set('inertia.ssr.bundle', '/nonexistent/path/ssr.mjs'); + + $this->artisan('inertia:start-ssr') + ->expectsOutput('Inertia SSR bundle not found at the configured path: "/nonexistent/path/ssr.mjs"') + ->assertExitCode(1); + } + + public function testErrorWhenNoBundleConfiguredAndDetectionFails(): void + { + config()->set('inertia.ssr.bundle', null); + + $this->artisan('inertia:start-ssr') + ->expectsOutput('Inertia SSR bundle not found. Set the correct Inertia SSR bundle path in your `inertia.ssr.bundle` config.') + ->assertExitCode(1); + } + + public function testBundleIsAutoDetectedWhenNotConfigured(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.bundle', null); + + $bundlePath = base_path('bootstrap/ssr/ssr.mjs'); + @mkdir(dirname($bundlePath), recursive: true); + file_put_contents($bundlePath, ''); + + try { + $this->artisan('inertia:start-ssr')->assertExitCode(0); + + $this->assertSame($bundlePath, $this->processCommand[1]); + } finally { + @unlink($bundlePath); + @rmdir(base_path('bootstrap/ssr')); + } + } + + public function testRuntimeDefaultsToNode(): void + { + $this->fakeProcess(); + + $this->artisan('inertia:start-ssr')->assertExitCode(0); + + $this->assertSame('node', $this->processCommand[0]); + } + + public function testRuntimeCanBeConfigured(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.runtime', 'bun'); + + $this->artisan('inertia:start-ssr')->assertExitCode(0); + + $this->assertSame('bun', $this->processCommand[0]); + } + + public function testRuntimeCanBeAnAbsolutePath(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.runtime', '/usr/local/bin/node'); + + $this->artisan('inertia:start-ssr')->assertExitCode(0); + + $this->assertSame('/usr/local/bin/node', $this->processCommand[0]); + } + + public function testRuntimeOptionOverridesConfig(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.runtime', 'bun'); + + $this->artisan('inertia:start-ssr', ['--runtime' => '/custom/path/node'])->assertExitCode(0); + + $this->assertSame('/custom/path/node', $this->processCommand[0]); + } + + public function testEnsureRuntimeExistsFailsWhenRuntimeNotFound(): void + { + config()->set('inertia.ssr.ensure_runtime_exists', true); + config()->set('inertia.ssr.runtime', 'nonexistent-runtime-binary'); + + $this->artisan('inertia:start-ssr') + ->expectsOutput('SSR runtime "nonexistent-runtime-binary" could not be found.') + ->assertExitCode(1); + } + + public function testEnsureRuntimeExistsPassesWhenRuntimeFound(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.ensure_runtime_exists', true); + config()->set('inertia.ssr.runtime', 'php'); + + $this->artisan('inertia:start-ssr')->assertExitCode(0); + } + + public function testRuntimeIsNotCheckedByDefault(): void + { + $this->fakeProcess(); + config()->set('inertia.ssr.runtime', 'nonexistent-runtime-binary'); + + $this->artisan('inertia:start-ssr')->assertExitCode(0); + + $this->assertSame('nonexistent-runtime-binary', $this->processCommand[0]); + } +} From d3a302d4779f297f5a962af5b85e7f406834b247 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:34 +0000 Subject: [PATCH 75/83] Port AssertableInertiaTest --- .../Inertia/Testing/AssertableInertiaTest.php | 516 ++++++++++++++++++ 1 file changed, 516 insertions(+) create mode 100644 tests/Inertia/Testing/AssertableInertiaTest.php diff --git a/tests/Inertia/Testing/AssertableInertiaTest.php b/tests/Inertia/Testing/AssertableInertiaTest.php new file mode 100644 index 000000000..be491bead --- /dev/null +++ b/tests/Inertia/Testing/AssertableInertiaTest.php @@ -0,0 +1,516 @@ +makeMockRequest( + Inertia::render('foo') + ); + + $response->assertInertia(); + } + + public function testTheViewIsNotServedByInertia(): void + { + $response = $this->makeMockRequest(view('welcome')); + $response->assertOk(); // Make sure we can render the built-in Orchestra 'welcome' view.. + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Not a valid Inertia response.'); + + $response->assertInertia(); + } + + public function testTheComponentMatches(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $response->assertInertia(function ($inertia) { + $inertia->component('foo'); + }); + } + + public function testTheComponentDoesNotMatch(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected Inertia page component.'); + + $response->assertInertia(function ($inertia) { + $inertia->component('bar'); + }); + } + + public function testTheComponentExistsOnTheFilesystem(): void + { + $response = $this->makeMockRequest( + Inertia::render('Fixtures/ExamplePage') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + $response->assertInertia(function ($inertia) { + $inertia->component('Fixtures/ExamplePage'); + }); + } + + public function testTheComponentExistsOnTheFilesystemWhenAComponentResolverIsConfigured(): void + { + $calledWith = null; + + Inertia::transformComponentUsing(static function (string $name) use (&$calledWith): string { + $calledWith = $name; + + return "{$name}/Page"; + }); + + $response = $this->makeMockRequest( + Inertia::render('Fixtures/Example') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + + $response->assertInertia(function ($inertia) { + $inertia->component('Fixtures/Example/Page'); + }); + + $this->assertSame('Fixtures/Example', $calledWith); + } + + public function testTheComponentDoesNotExistOnTheFilesystem(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia page component file [foo] does not exist.'); + + $response->assertInertia(function ($inertia) { + $inertia->component('foo'); + }); + } + + public function testItCanForceEnableTheComponentFileExistence(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + config()->set('inertia.testing.ensure_pages_exist', false); + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia page component file [foo] does not exist.'); + + $response->assertInertia(function ($inertia) { + $inertia->component('foo', true); + }); + } + + public function testItCanForceDisableTheComponentFileExistenceCheck(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + + $response->assertInertia(function ($inertia) { + $inertia->component('foo', false); + }); + } + + public function testTheComponentDoesNotExistOnTheFilesystemWhenItDoesNotExistRelativeToAnyOfTheGivenPaths(): void + { + $response = $this->makeMockRequest( + Inertia::render('fixtures/ExamplePage') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + config()->set('inertia.pages.paths', [realpath(__DIR__)]); + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia page component file [fixtures/ExamplePage] does not exist.'); + + $response->assertInertia(function ($inertia) { + $inertia->component('fixtures/ExamplePage'); + }); + } + + public function testTheComponentDoesNotExistOnTheFilesystemWhenItDoesNotHaveOneOfTheConfiguredExtensions(): void + { + $response = $this->makeMockRequest( + Inertia::render('fixtures/ExamplePage') + ); + + config()->set('inertia.testing.ensure_pages_exist', true); + config()->set('inertia.pages.extensions', ['bin', 'exe', 'svg']); + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia page component file [fixtures/ExamplePage] does not exist.'); + + $response->assertInertia(function ($inertia) { + $inertia->component('fixtures/ExamplePage'); + }); + } + + public function testThePageUrlMatches(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $response->assertInertia(function ($inertia) { + $inertia->url('/example-url'); + }); + } + + public function testThePageUrlDoesNotMatch(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected Inertia page url.'); + + $response->assertInertia(function ($inertia) { + $inertia->url('/invalid-page'); + }); + } + + public function testTheAssetVersionMatches(): void + { + Inertia::version('example-version'); + + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $response->assertInertia(function ($inertia) { + $inertia->version('example-version'); + }); + } + + public function testTheAssetVersionDoesNotMatch(): void + { + Inertia::version('example-version'); + + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Unexpected Inertia asset version.'); + + $response->assertInertia(function ($inertia) { + $inertia->version('different-version'); + }); + } + + public function testReloadingAVisit(): void + { + $foo = 0; + + $response = $this->makeMockRequest(function () use (&$foo) { + return Inertia::render('foo', [ + 'foo' => $foo++, + ]); + }); + + $called = false; + + $response->assertInertia(function ($inertia) use (&$called) { + $inertia->where('foo', 0); + + $inertia->reload(function ($inertia) use (&$called) { + $inertia->where('foo', 1); + $called = true; + }); + }); + + $this->assertTrue($called); + } + + public function testOptionalPropsCanBeEvaluated(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'foo' => 'bar', + 'optional1' => Inertia::optional(fn () => 'baz'), + 'optional2' => Inertia::optional(fn () => 'qux'), + ]) + ); + + $called = false; + + $response->assertInertia(function ($inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('optional1'); + $inertia->missing('optional2'); + + $result = $inertia->reloadOnly('optional1', function ($inertia) use (&$called) { + $inertia->missing('foo'); + $inertia->where('optional1', 'baz'); + $inertia->missing('optional2'); + $called = true; + }); + + $this->assertSame($result, $inertia); + }); + + $this->assertTrue($called); + } + + public function testOptionalPropsCanBeEvaluatedWithExcept(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'foo' => 'bar', + 'lazy1' => Inertia::optional(fn () => 'baz'), + 'lazy2' => Inertia::optional(fn () => 'qux'), + ]) + ); + + $called = false; + + $response->assertInertia(function ($inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('lazy1'); + $inertia->missing('lazy2'); + + $result = $inertia->reloadOnly(['lazy1'], function ($inertia) use (&$called) { + $inertia->missing('foo'); + $inertia->where('lazy1', 'baz'); + $inertia->missing('lazy2'); + $called = true; + }); + + $this->assertSame($result, $inertia); + }); + + $this->assertTrue($called); + } + + public function testLazyPropsCanBeEvaluatedWithExcept(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'foo' => 'bar', + 'optional1' => Inertia::optional(fn () => 'baz'), + 'optional2' => Inertia::optional(fn () => 'qux'), + ]) + ); + + $called = false; + + $response->assertInertia(function (AssertableInertia $inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('optional1'); + $inertia->missing('optional2'); + + $inertia->reloadExcept('optional1', function ($inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('optional1'); + $inertia->where('optional2', 'qux'); + $called = true; + }); + }); + + $this->assertTrue($called); + } + + public function testLazyPropsCanBeEvaluatedWithExceptWhenExceptIsArray(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'foo' => 'bar', + 'lazy1' => Inertia::optional(fn () => 'baz'), + 'lazy2' => Inertia::optional(fn () => 'qux'), + ]) + ); + + $called = false; + + $response->assertInertia(function ($inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('lazy1'); + $inertia->missing('lazy2'); + + $inertia->reloadExcept(['lazy1'], function ($inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('lazy1'); + $inertia->where('lazy2', 'qux'); + $called = true; + }); + }); + + $this->assertTrue($called); + } + + public function testAssertAgainstDeferredProps(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'foo' => 'bar', + 'deferred1' => Inertia::defer(fn () => 'baz'), + 'deferred2' => Inertia::defer(fn () => 'qux', 'custom'), + 'deferred3' => Inertia::defer(fn () => 'quux', 'custom'), + ]) + ); + + $called = 0; + + $response->assertInertia(function (AssertableInertia $inertia) use (&$called) { + $inertia->where('foo', 'bar'); + $inertia->missing('deferred1'); + $inertia->missing('deferred2'); + $inertia->missing('deferred3'); + + $inertia->loadDeferredProps(function (AssertableInertia $inertia) use (&$called) { + $inertia->where('deferred1', 'baz'); + $inertia->where('deferred2', 'qux'); + $inertia->where('deferred3', 'quux'); + ++$called; + }); + + $inertia->loadDeferredProps('default', function (AssertableInertia $inertia) use (&$called) { + $inertia->where('deferred1', 'baz'); + $inertia->missing('deferred2'); + $inertia->missing('deferred3'); + ++$called; + }); + + $inertia->loadDeferredProps('custom', function (AssertableInertia $inertia) use (&$called) { + $inertia->missing('deferred1'); + $inertia->where('deferred2', 'qux'); + $inertia->where('deferred3', 'quux'); + ++$called; + }); + + $inertia->loadDeferredProps(['default', 'custom'], function (AssertableInertia $inertia) use (&$called) { + $inertia->where('deferred1', 'baz'); + $inertia->where('deferred2', 'qux'); + $inertia->where('deferred3', 'quux'); + ++$called; + }); + }); + + $this->assertSame(4, $called); + } + + public function testTheFlashDataCanBeAsserted(): void + { + $response = $this->makeMockRequest( + fn () => Inertia::render('foo')->flash([ + 'message' => 'Hello World', + 'notification' => ['type' => 'success'], + ]), + StartSession::class + ); + + $response->assertInertia(function (AssertableInertia $inertia) { + $inertia->hasFlash('message'); + $inertia->hasFlash('message', 'Hello World'); + $inertia->hasFlash('notification.type', 'success'); + $inertia->missingFlash('other'); + $inertia->missingFlash('notification.other'); + }); + } + + public function testTheFlashAssertionFailsWhenKeyIsMissing(): void + { + $response = $this->makeMockRequest(Inertia::render('foo')); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data is missing key [message].'); + + $response->assertInertia(fn (AssertableInertia $inertia) => $inertia->hasFlash('message')); + } + + public function testTheFlashAssertionFailsWhenValueDoesNotMatch(): void + { + $response = $this->makeMockRequest( + fn () => Inertia::render('foo')->flash('message', 'Hello World'), + StartSession::class + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data [message] does not match expected value.'); + + $response->assertInertia(fn (AssertableInertia $inertia) => $inertia->hasFlash('message', 'Different')); + } + + public function testTheMissingFlashAssertionFailsWhenKeyExists(): void + { + $response = $this->makeMockRequest( + fn () => Inertia::render('foo')->flash('message', 'Hello World'), + StartSession::class + ); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data has unexpected key [message].'); + + $response->assertInertia(fn (AssertableInertia $inertia) => $inertia->missingFlash('message')); + } + + public function testTheFlashDataIsAvailableAfterRedirect(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->get('/action', function () { + Inertia::flash('message', 'Success!'); + + return redirect('/dashboard'); + }); + + Route::middleware($middleware)->get('/dashboard', function () { + return Inertia::render('Dashboard'); + }); + + $this->get('/action')->assertRedirect('/dashboard'); + $this->get('/dashboard')->assertInertia(fn (AssertableInertia $inertia) => $inertia->hasFlash('message', 'Success!')); + } + + public function testTheFlashDataIsAvailableAfterDoubleRedirect(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->get('/action', function () { + Inertia::flash('message', 'Success!'); + + return redirect('/intermediate'); + }); + + Route::middleware($middleware)->get('/intermediate', function () { + return redirect('/dashboard'); + }); + + Route::middleware($middleware)->get('/dashboard', function () { + return Inertia::render('Dashboard'); + }); + + $this->get('/action')->assertRedirect('/intermediate'); + $this->get('/intermediate')->assertRedirect('/dashboard'); + $this->get('/dashboard')->assertInertia(fn (AssertableInertia $inertia) => $inertia->hasFlash('message', 'Success!')); + } +} From 16081385176df0d09693b722d906f76c8420b07b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:34 +0000 Subject: [PATCH 76/83] Port TestResponseMacrosTest --- .../Testing/TestResponseMacrosTest.php | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/Inertia/Testing/TestResponseMacrosTest.php diff --git a/tests/Inertia/Testing/TestResponseMacrosTest.php b/tests/Inertia/Testing/TestResponseMacrosTest.php new file mode 100644 index 000000000..1c0ab9bbd --- /dev/null +++ b/tests/Inertia/Testing/TestResponseMacrosTest.php @@ -0,0 +1,152 @@ +makeMockRequest( + Inertia::render('foo') + ); + + $success = false; + $response->assertInertia(function ($page) use (&$success) { + $this->assertInstanceOf(AssertableJson::class, $page); + $success = true; + }); + + $this->assertTrue($success); + } + + public function testItPreservesTheAbilityToContinueChainingLaravelTestResponseCalls(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo') + ); + + $this->assertInstanceOf( + TestResponse::class, + $response->assertInertia() + ); + } + + public function testItCanRetrieveTheInertiaPage(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', ['bar' => 'baz']) + ); + + tap($response->inertiaPage(), function (array $page) { + $this->assertSame('foo', $page['component']); + $this->assertSame(['bar' => 'baz'], $page['props']); + $this->assertSame('/example-url', $page['url']); + $this->assertSame('', $page['version']); + $this->assertArrayNotHasKey('encryptHistory', $page); + $this->assertArrayNotHasKey('clearHistory', $page); + }); + } + + public function testItCanRetrieveTheInertiaProps(): void + { + $props = ['bar' => 'baz']; + $response = $this->makeMockRequest( + Inertia::render('foo', $props) + ); + + $this->assertSame($props, $response->inertiaProps()); + } + + public function testItCanRetrieveNestedInertiaPropValuesWithDotNotation(): void + { + $response = $this->makeMockRequest( + Inertia::render('foo', [ + 'bar' => ['baz' => 'qux'], + 'users' => [ + ['name' => 'John'], + ['name' => 'Jane'], + ], + ]) + ); + + $this->assertSame('qux', $response->inertiaProps('bar.baz')); + $this->assertSame('John', $response->inertiaProps('users.0.name')); + } + + public function testItCanAssertFlashDataOnRedirectResponses(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->post('/users', function () { + return Inertia::flash([ + 'message' => 'User created!', + 'notification' => ['type' => 'success'], + ])->back(); + }); + + $this->post('/users') + ->assertRedirect() + ->assertInertiaFlash('message') + ->assertInertiaFlash('message', 'User created!') + ->assertInertiaFlash('notification.type', 'success') + ->assertInertiaFlashMissing('error') + ->assertInertiaFlashMissing('notification.other'); + } + + public function testAssertHasInertiaFlashFailsWhenKeyIsMissing(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->post('/users', function () { + return Inertia::flash('message', 'Hello')->back(); + }); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data is missing key [other].'); + + $this->post('/users')->assertInertiaFlash('other'); + } + + public function testAssertHasInertiaFlashFailsWhenValueDoesNotMatch(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->post('/users', function () { + return Inertia::flash('message', 'Hello')->back(); + }); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data [message] does not match expected value.'); + + $this->post('/users')->assertInertiaFlash('message', 'Different'); + } + + public function testAssertMissingInertiaFlashFailsWhenKeyExists(): void + { + $middleware = [StartSession::class, Middleware::class]; + + Route::middleware($middleware)->post('/users', function () { + return Inertia::flash('message', 'Hello')->back(); + }); + + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessage('Inertia Flash Data has unexpected key [message].'); + + $this->post('/users')->assertInertiaFlashMissing('message'); + } +} From b2ac7fe5a5e2f70f2fbbb4b37ef2a5330c97a176 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:44 +0000 Subject: [PATCH 77/83] BundleDetector caching tests --- tests/Inertia/BundleDetectorTest.php | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/Inertia/BundleDetectorTest.php diff --git a/tests/Inertia/BundleDetectorTest.php b/tests/Inertia/BundleDetectorTest.php new file mode 100644 index 000000000..19eece321 --- /dev/null +++ b/tests/Inertia/BundleDetectorTest.php @@ -0,0 +1,45 @@ +set('inertia.ssr.bundle', __FILE__); + + $detector = new BundleDetector; + + $first = $detector->detect(); + $this->assertSame(__FILE__, $first); + + // Change config — cached result should still be returned + config()->set('inertia.ssr.bundle', '/nonexistent/path'); + + $second = $detector->detect(); + $this->assertSame(__FILE__, $second); + } + + public function testFlushStateResetsCache() + { + config()->set('inertia.ssr.bundle', __FILE__); + + $detector = new BundleDetector; + $detector->detect(); + + BundleDetector::flushState(); + + // After flush, config change is picked up + config()->set('inertia.ssr.bundle', null); + + $this->assertNull($detector->detect()); + } +} From 95413a8cb819cdc73514a1cb6bb0b48aadc81929 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:44 +0000 Subject: [PATCH 78/83] Coroutine isolation tests for InertiaState --- tests/Inertia/CoroutineIsolationTest.php | 85 ++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/Inertia/CoroutineIsolationTest.php diff --git a/tests/Inertia/CoroutineIsolationTest.php b/tests/Inertia/CoroutineIsolationTest.php new file mode 100644 index 000000000..357accfe4 --- /dev/null +++ b/tests/Inertia/CoroutineIsolationTest.php @@ -0,0 +1,85 @@ +share('user', 'Alice'); + usleep(1000); + + return $factory->getShared('user'); + }, + function () { + $factory = new ResponseFactory; + $factory->share('user', 'Bob'); + usleep(1000); + + return $factory->getShared('user'); + }, + ]); + + $this->assertContains('Alice', $results); + $this->assertContains('Bob', $results); + $this->assertCount(2, $results); + } + + public function testRootViewIsIsolatedBetweenCoroutines() + { + $results = parallel([ + function () { + $factory = new ResponseFactory; + $factory->setRootView('layout-a'); + usleep(1000); + + return CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState)->rootView; + }, + function () { + $factory = new ResponseFactory; + $factory->setRootView('layout-b'); + usleep(1000); + + return CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState)->rootView; + }, + ]); + + $this->assertContains('layout-a', $results); + $this->assertContains('layout-b', $results); + } + + public function testInertiaStateIsDestroyedWhenCoroutineEnds() + { + // First parallel block: coroutine sets state then ends + parallel([ + function () { + $factory = new ResponseFactory; + $factory->share('key', 'value'); + }, + ]); + + // Second parallel block: new coroutine should not see the state + $results = parallel([ + function () { + return CoroutineContext::get(InertiaState::CONTEXT_KEY); + }, + ]); + + $this->assertNull($results[0]); + } +} From 80b8ca5f05535acbad61962feabbe9488bd304a1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:04:51 +0000 Subject: [PATCH 79/83] Document request context behavior in porting guide --- docs/ai/porting.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/ai/porting.md b/docs/ai/porting.md index 6b5d55540..7ecf66f04 100644 --- a/docs/ai/porting.md +++ b/docs/ai/porting.md @@ -540,6 +540,10 @@ All tests run inside coroutines by default. The `RunTestsInCoroutine` trait is o These are primarily useful for DB operations or external service setup that needs coroutine context. Most ported Laravel tests won't need them. +#### Request Context in Tests + +`request()` resolves from `RequestContext` — when no request exists in context (tests that don't make HTTP requests), each `request()` call creates a throwaway fallback instance. This means `request()->merge()` has no effect on subsequent `request()` calls. Replace `request()->merge(['key' => 'value'])` with `RequestContext::set(Request::create('/?key=value'))` to seed a stable request in context. + #### Static State and Test Cleanup `AfterEachTestSubscriber` handles global static state cleanup between tests. It calls `flushState()` on framework classes that accumulate static state (Mockery, HandleExceptions, Carbon, Number, Eloquent Model, Paginator, etc.). **Never add cleanup for these in `tearDown()`** — it's already handled. From 37e4aea430b7ba05c214df9caae1c6b7219cbed6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:05:31 +0000 Subject: [PATCH 80/83] CheckSsr artisan command --- src/inertia/src/Commands/CheckSsr.php | 42 +++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/inertia/src/Commands/CheckSsr.php diff --git a/src/inertia/src/Commands/CheckSsr.php b/src/inertia/src/Commands/CheckSsr.php new file mode 100644 index 000000000..56b1a244c --- /dev/null +++ b/src/inertia/src/Commands/CheckSsr.php @@ -0,0 +1,42 @@ +error('The SSR gateway does not support health checks.'); + + return self::FAILURE; + } + + ($check = $gateway->isHealthy()) + ? $this->info('Inertia SSR server is running.') + : $this->error('Inertia SSR server is not running.'); + + return $check ? self::SUCCESS : self::FAILURE; + } +} From 7c2401f6be65361759cad6399120fa59172003d5 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:05:31 +0000 Subject: [PATCH 81/83] CreateMiddleware generator command --- src/inertia/src/Commands/CreateMiddleware.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/inertia/src/Commands/CreateMiddleware.php diff --git a/src/inertia/src/Commands/CreateMiddleware.php b/src/inertia/src/Commands/CreateMiddleware.php new file mode 100644 index 000000000..b604c1953 --- /dev/null +++ b/src/inertia/src/Commands/CreateMiddleware.php @@ -0,0 +1,68 @@ +> + */ + protected function getArguments(): array + { + return [ + ['name', InputOption::VALUE_REQUIRED, 'Name of the Middleware that should be created', 'HandleInertiaRequests'], + ]; + } + + /** + * Get the console command options. + * + * @return array> + */ + protected function getOptions(): array + { + return [ + ['force', null, InputOption::VALUE_NONE, 'Create the class even if the Middleware already exists'], + ]; + } +} From de0cef46bf1544734bcb47abc1ba57a04afee08a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:05:31 +0000 Subject: [PATCH 82/83] StartSsr artisan command --- src/inertia/src/Commands/StartSsr.php | 90 +++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/inertia/src/Commands/StartSsr.php diff --git a/src/inertia/src/Commands/StartSsr.php b/src/inertia/src/Commands/StartSsr.php new file mode 100644 index 000000000..51a8f4d0d --- /dev/null +++ b/src/inertia/src/Commands/StartSsr.php @@ -0,0 +1,90 @@ +error('Inertia SSR is not enabled. Enable it via the `inertia.ssr.enabled` config option.'); + + return self::FAILURE; + } + + $bundle = (new BundleDetector)->detect(); + $configuredBundle = config('inertia.ssr.bundle'); + + if ($bundle === null) { + $this->error( + $configuredBundle + ? 'Inertia SSR bundle not found at the configured path: "' . $configuredBundle . '"' + : 'Inertia SSR bundle not found. Set the correct Inertia SSR bundle path in your `inertia.ssr.bundle` config.' + ); + + return self::FAILURE; + } + if ($configuredBundle && $bundle !== $configuredBundle) { + $this->warn('Inertia SSR bundle not found at the configured path: "' . $configuredBundle . '"'); + $this->warn('Using a default bundle instead: "' . $bundle . '"'); + } + + $runtime = $this->option('runtime') ?? config('inertia.ssr.runtime', 'node'); + + if (config('inertia.ssr.ensure_runtime_exists', false) && ! (new ExecutableFinder)->find($runtime)) { + $this->error('SSR runtime "' . $runtime . '" could not be found.'); + + return self::FAILURE; + } + + $this->callSilently('inertia:stop-ssr'); + + $process = app(Process::class, ['command' => [$runtime, $bundle]]); + $process->setTimeout(null); + $process->start(); + + if (extension_loaded('pcntl')) { + $stop = function () use ($process) { + $process->stop(); + }; + pcntl_async_signals(true); + pcntl_signal(SIGINT, $stop); + pcntl_signal(SIGQUIT, $stop); + pcntl_signal(SIGTERM, $stop); + } + + foreach ($process as $type => $data) { + if ($process::OUT === $type) { + $this->info(trim($data)); + } else { + $this->error(trim($data)); + report(new SsrException($data)); + } + } + + return self::SUCCESS; + } +} From b419b156a77184d078a7a36b825ed0eef775e24c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:05:32 +0000 Subject: [PATCH 83/83] StopSsr command with Http facade replacing raw curl --- src/inertia/src/Commands/StopSsr.php | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/inertia/src/Commands/StopSsr.php diff --git a/src/inertia/src/Commands/StopSsr.php b/src/inertia/src/Commands/StopSsr.php new file mode 100644 index 000000000..d486e36b2 --- /dev/null +++ b/src/inertia/src/Commands/StopSsr.php @@ -0,0 +1,50 @@ +getProductionUrl('/shutdown'); + + try { + Http::timeout(3)->get($url); + } catch (ConnectionException $e) { + // The shutdown endpoint closes the connection without a response, + // which triggers an "Empty reply from server" error. This is expected. + // Real connection failures produce "Connection refused" or similar. + if (! str_contains($e->getMessage(), 'Empty reply from server')) { + $this->error('Unable to connect to Inertia SSR server.'); + + return self::FAILURE; + } + } + + $this->info('Inertia SSR server stopped.'); + + return self::SUCCESS; + } +}