From 5cd90f5086c6caa1a7f8064e2db0fc43254a3304 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:16:33 +0000 Subject: [PATCH 1/9] fix(inertia): isolate request rendering state Replicate provider-boot defaults into a request-local InertiaState and route every state consumer through that owner. Centralize SSR dispatch, keep the resolved page authoritative, and prevent view data from replacing framework protocol data. Make component and directive fallback JSON failures explicit, avoid duplicate page encoding after successful SSR, preserve exact root identifiers and integer prop keys, and cover boot inheritance, sibling isolation, dispatch identity, and rendering equivalence. --- src/inertia/src/Directive.php | 13 +- src/inertia/src/InertiaState.php | 63 ++++++--- src/inertia/src/Response.php | 15 +-- src/inertia/src/ResponseFactory.php | 5 +- src/inertia/src/View/Components/App.php | 18 ++- src/inertia/src/View/Components/Head.php | 14 +- tests/Inertia/ComponentTest.php | 65 ++++++--- tests/Inertia/CoroutineIsolationTest.php | 159 ++++++++++++++++++++++- tests/Inertia/DirectiveTest.php | 26 ++++ tests/Inertia/ResponseFactoryTest.php | 18 +++ tests/Inertia/ResponseTest.php | 21 +++ 11 files changed, 334 insertions(+), 83 deletions(-) diff --git a/src/inertia/src/Directive.php b/src/inertia/src/Directive.php index 7d94cd764..feae0809a 100644 --- a/src/inertia/src/Directive.php +++ b/src/inertia/src/Directive.php @@ -13,15 +13,18 @@ class Directive */ public static function compile(string $expression = ''): string { - $id = trim(trim($expression), "\\'\"") ?: 'app'; + $id = trim(trim($expression), "\\'\""); + $id = $id === '' ? 'app' : $id; $template = 'page = $page; + $__inertiaSsrResponse = $__inertiaState->dispatchSsr(); if ($__inertiaSsrResponse) { echo $__inertiaSsrResponse->body; } else { - ?>
'; @@ -36,7 +39,9 @@ public static function compile(string $expression = ''): string public static function compileHead(string $expression = ''): string { $template = 'page = $page; + $__inertiaSsrResponse = $__inertiaState->dispatchSsr(); if ($__inertiaSsrResponse) { echo $__inertiaSsrResponse->head; diff --git a/src/inertia/src/InertiaState.php b/src/inertia/src/InertiaState.php index 5efecbd57..fc9d78cdf 100644 --- a/src/inertia/src/InertiaState.php +++ b/src/inertia/src/InertiaState.php @@ -6,17 +6,18 @@ use Closure; use Hypervel\Context\CoroutineContext; +use Hypervel\Context\ReplicableContext; use Hypervel\Inertia\Ssr\Gateway; use Hypervel\Inertia\Ssr\Response as SsrResponse; /** - * Per-request Inertia state stored in coroutine Context. + * Inertia configuration and request state stored in coroutine Context. * - * All request-scoped Inertia state lives here instead of on singleton - * service classes. This ensures complete isolation between concurrent - * requests in Swoole's long-running worker model. + * Providers configure one boot baseline outside a coroutine. Each request + * receives an independent copy so request mutations remain isolated in + * Swoole's long-running worker model. */ -class InertiaState +class InertiaState implements ReplicableContext { /** * The coroutine Context key for this state. @@ -31,7 +32,7 @@ class InertiaState /** * The shared properties included in every Inertia response. * - * @var array + * @var array */ public array $sharedProps = []; @@ -89,24 +90,46 @@ class InertiaState 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 + * Get the current Inertia state. */ - public static function dispatchSsr(array $page): ?SsrResponse + public static function current(): self { - $state = CoroutineContext::getOrSet(self::CONTEXT_KEY, fn () => new self); - $state->page = $page; + if (CoroutineContext::has(self::CONTEXT_KEY)) { + /** @var self $state */ + $state = CoroutineContext::get(self::CONTEXT_KEY); - if (! $state->ssrDispatched) { - $state->ssrDispatched = true; - $state->ssrResponse = app(Gateway::class)->dispatch($state->page); + return $state; } - return $state->ssrResponse; + // Providers configure Inertia before the server starts request coroutines, + // so each request begins with an independent copy of that boot baseline. + /** @var null|self $baseline */ + $baseline = CoroutineContext::getFromNonCoroutine(self::CONTEXT_KEY); + + $state = $baseline?->replicate() ?? new self; + CoroutineContext::set(self::CONTEXT_KEY, $state); + + return $state; + } + + /** + * Dispatch SSR if it has not already been dispatched for this request. + */ + public function dispatchSsr(): ?SsrResponse + { + if (! $this->ssrDispatched) { + $this->ssrDispatched = true; + $this->ssrResponse = app(Gateway::class)->dispatch($this->page); + } + + return $this->ssrResponse; + } + + /** + * Create an independent copy with the same state. + */ + public function replicate(): static + { + return clone $this; } } diff --git a/src/inertia/src/Response.php b/src/inertia/src/Response.php index 7bc77e7ac..84f1b7174 100644 --- a/src/inertia/src/Response.php +++ b/src/inertia/src/Response.php @@ -6,7 +6,6 @@ use BackedEnum; use Closure; -use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Support\Responsable; use Hypervel\Http\JsonResponse; use Hypervel\Http\Request; @@ -31,7 +30,7 @@ class Response implements Responsable /** * The page props. * - * @var array + * @var array */ protected array $props; @@ -108,11 +107,10 @@ public function __construct( /** * Add additional properties to the page. * - * @param array|ProvidesInertiaProperties|string $key - * @param mixed $value + * @param array|int|ProvidesInertiaProperties|string $key * @return $this */ - public function with($key, $value = null): self + public function with(array|ProvidesInertiaProperties|int|string $key, mixed $value = null): self { if ($key instanceof ProvidesInertiaProperties) { $this->props[] = $key; @@ -129,10 +127,9 @@ public function with($key, $value = null): self * Add additional data to the view. * * @param array|string $key - * @param mixed $value * @return $this */ - public function withViewData($key, $value = null): self + public function withViewData(array|string $key, mixed $value = null): self { if (is_array($key)) { $this->viewData = array_merge($this->viewData, $key); @@ -194,9 +191,9 @@ public function toResponse(Request $request): SymfonyResponse return new JsonResponse($page, 200, [Header::INERTIA => 'true']); } - CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState)->page = $page; + InertiaState::current()->page = $page; - return ResponseFactory::view($this->rootView, $this->viewData + ['page' => $page]); + return ResponseFactory::view($this->rootView, ['page' => $page] + $this->viewData); } /** diff --git a/src/inertia/src/ResponseFactory.php b/src/inertia/src/ResponseFactory.php index 279c2e165..25e1618c4 100644 --- a/src/inertia/src/ResponseFactory.php +++ b/src/inertia/src/ResponseFactory.php @@ -6,7 +6,6 @@ use BackedEnum; use Closure; -use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Contracts\Http\Kernel; use Hypervel\Contracts\Support\Arrayable; @@ -39,7 +38,7 @@ class ResponseFactory */ private function state(): InertiaState { - return CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); + return InertiaState::current(); } /** @@ -83,7 +82,7 @@ public function getShared(?string $key = null, mixed $default = null): mixed { $sharedProps = $this->state()->sharedProps; - if ($key) { + if ($key !== null) { return Arr::get($sharedProps, $key, $default); } diff --git a/src/inertia/src/View/Components/App.php b/src/inertia/src/View/Components/App.php index bfd3358c6..8b72452d0 100644 --- a/src/inertia/src/View/Components/App.php +++ b/src/inertia/src/View/Components/App.php @@ -4,9 +4,7 @@ namespace Hypervel\Inertia\View\Components; -use Hypervel\Context\CoroutineContext; use Hypervel\Inertia\InertiaState; -use Hypervel\Inertia\Ssr\Gateway; use Hypervel\Inertia\Ssr\Response; use Hypervel\View\Component; @@ -16,18 +14,18 @@ class App extends Component public string $pageJson; + /** + * Create a new Inertia application component. + */ public function __construct( public string $id = 'app', ) { - $state = CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); - - if (! $state->ssrDispatched) { - $state->ssrDispatched = true; - $state->ssrResponse = app(Gateway::class)->dispatch($state->page); - } + $state = InertiaState::current(); - $this->response = $state->ssrResponse; - $this->pageJson = (string) json_encode($state->page); + $this->response = $state->dispatchSsr(); + $this->pageJson = $this->response === null + ? json_encode($state->page, JSON_THROW_ON_ERROR) + : ''; } /** diff --git a/src/inertia/src/View/Components/Head.php b/src/inertia/src/View/Components/Head.php index cfe802420..b9d1be274 100644 --- a/src/inertia/src/View/Components/Head.php +++ b/src/inertia/src/View/Components/Head.php @@ -4,9 +4,7 @@ namespace Hypervel\Inertia\View\Components; -use Hypervel\Context\CoroutineContext; use Hypervel\Inertia\InertiaState; -use Hypervel\Inertia\Ssr\Gateway; use Hypervel\Inertia\Ssr\Response; use Hypervel\View\Component; @@ -14,16 +12,12 @@ class Head extends Component { public ?Response $response; + /** + * Create a new Inertia head component. + */ public function __construct() { - $state = CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); - - if (! $state->ssrDispatched) { - $state->ssrDispatched = true; - $state->ssrResponse = app(Gateway::class)->dispatch($state->page); - } - - $this->response = $state->ssrResponse; + $this->response = InertiaState::current()->dispatchSsr(); } /** diff --git a/tests/Inertia/ComponentTest.php b/tests/Inertia/ComponentTest.php index ab793c514..8afa46453 100644 --- a/tests/Inertia/ComponentTest.php +++ b/tests/Inertia/ComponentTest.php @@ -10,6 +10,8 @@ use Hypervel\Support\Facades\Blade; use Hypervel\Support\Facades\Config; use Hypervel\Tests\Inertia\Fixtures\FakeGateway; +use Hypervel\View\ViewException; +use JsonException; class ComponentTest extends TestCase { @@ -25,7 +27,7 @@ protected function setUp(): void */ protected function renderView(string $contents, array $data = []): string { - $state = CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); + $state = InertiaState::current(); $state->page = $data['page'] ?? []; return Blade::render($contents, $data, true); @@ -39,7 +41,7 @@ protected function resetInertiaState(): void CoroutineContext::forget(InertiaState::CONTEXT_KEY); } - public function testHeadComponentRendersFallbackSlotWhenSsrIsDisabled() + public function testHeadComponentRendersFallbackSlotWhenSsrIsDisabled(): void { Config::set(['inertia.ssr.enabled' => false]); @@ -51,7 +53,7 @@ public function testHeadComponentRendersFallbackSlotWhenSsrIsDisabled() ); } - public function testHeadComponentRendersSsrHeadWhenSsrIsEnabled() + public function testHeadComponentRendersSsrHeadWhenSsrIsEnabled(): void { Config::set(['inertia.ssr.enabled' => true]); @@ -62,7 +64,7 @@ public function testHeadComponentRendersSsrHeadWhenSsrIsEnabled() $this->assertStringNotContainsString('Fallback Title', $rendered); } - public function testAppComponentRendersClientSideDivWhenSsrIsDisabled() + public function testAppComponentRendersClientSideDivWhenSsrIsDisabled(): void { Config::set(['inertia.ssr.enabled' => false]); @@ -73,7 +75,27 @@ public function testAppComponentRendersClientSideDivWhenSsrIsDisabled() $this->assertStringContainsString('data-page="app"', $rendered); } - public function testAppComponentRendersSsrBodyWhenSsrIsEnabled() + public function testAppComponentReportsPageEncodingFailures(): void + { + Config::set(['inertia.ssr.enabled' => false]); + $resource = fopen('php://memory', 'r'); + + try { + try { + $this->renderView('', ['page' => ['value' => $resource]]); + } catch (ViewException $exception) { + $this->assertInstanceOf(JsonException::class, $exception->getPrevious()); + + return; + } + + $this->fail('The unencodable page did not throw a view exception.'); + } finally { + fclose($resource); + } + } + + public function testAppComponentRendersSsrBodyWhenSsrIsEnabled(): void { Config::set(['inertia.ssr.enabled' => true]); @@ -85,7 +107,7 @@ public function testAppComponentRendersSsrBodyWhenSsrIsEnabled() ); } - public function testAppComponentAcceptsCustomId() + public function testAppComponentAcceptsCustomId(): void { Config::set(['inertia.ssr.enabled' => false]); @@ -96,7 +118,7 @@ public function testAppComponentAcceptsCustomId() $this->assertStringContainsString('data-page="custom"', $rendered); } - public function testSsrIsOnlyDispatchedOnceWithComponents() + public function testSsrIsOnlyDispatchedOnceWithComponents(): void { Config::set(['inertia.ssr.enabled' => true]); $this->app->instance(Gateway::class, $gateway = new FakeGateway); @@ -107,7 +129,7 @@ public function testSsrIsOnlyDispatchedOnceWithComponents() $this->assertSame(1, $gateway->times); } - public function testAppComponentMatchesDirectiveOutputWhenSsrIsDisabled() + public function testAppComponentMatchesDirectiveOutputWhenSsrIsDisabled(): void { Config::set(['inertia.ssr.enabled' => false]); @@ -120,20 +142,23 @@ public function testAppComponentMatchesDirectiveOutputWhenSsrIsDisabled() $this->assertSame($directive, $component); } - public function testAppComponentMatchesDirectiveOutputWhenSsrIsEnabled() + public function testAppComponentMatchesDirectiveOutputWhenSsrIsEnabled(): void { Config::set(['inertia.ssr.enabled' => true]); + $page = ['value' => "\xB1\x31"]; - $directive = $this->renderView('@inertia', ['page' => self::EXAMPLE_PAGE_OBJECT]); + // FakeGateway supplies the SSR result without encoding the page, proving + // that neither rendering path performs its client-fallback encoding. + $directive = $this->renderView('@inertia', ['page' => $page]); $this->resetInertiaState(); - $component = trim($this->renderView('', ['page' => self::EXAMPLE_PAGE_OBJECT])); + $component = trim($this->renderView('', ['page' => $page])); $this->assertSame($directive, $component); } - public function testAppComponentWithCustomIdMatchesDirectiveOutput() + public function testAppComponentWithCustomIdMatchesDirectiveOutput(): void { Config::set(['inertia.ssr.enabled' => false]); @@ -146,7 +171,7 @@ public function testAppComponentWithCustomIdMatchesDirectiveOutput() $this->assertSame($directive, $component); } - public function testHeadComponentWithoutSlotMatchesDirectiveOutputWhenSsrIsDisabled() + public function testHeadComponentWithoutSlotMatchesDirectiveOutputWhenSsrIsDisabled(): void { Config::set(['inertia.ssr.enabled' => false]); @@ -159,7 +184,7 @@ public function testHeadComponentWithoutSlotMatchesDirectiveOutputWhenSsrIsDisab $this->assertSame($directive, $component); } - public function testHeadComponentWithoutSlotMatchesDirectiveOutputWhenSsrIsEnabled() + public function testHeadComponentWithoutSlotMatchesDirectiveOutputWhenSsrIsEnabled(): void { Config::set(['inertia.ssr.enabled' => true]); @@ -172,7 +197,7 @@ public function testHeadComponentWithoutSlotMatchesDirectiveOutputWhenSsrIsEnabl $this->assertSame($directive, $component); } - public function testComponentsDoNotCreateCachedViewFilesPerRequest() + public function testComponentsDoNotCreateCachedViewFilesPerRequest(): void { Config::set(['inertia.ssr.enabled' => true]); @@ -188,7 +213,7 @@ public function testComponentsDoNotCreateCachedViewFilesPerRequest() $this->assertSame($cachedViews, glob($viewCachePath . '/*.php')); } - public function testAppComponentRendersCurrentPageNotPreviousRender() + public function testAppComponentRendersCurrentPageNotPreviousRender(): void { Config::set(['inertia.ssr.enabled' => false]); @@ -204,21 +229,21 @@ public function testAppComponentRendersCurrentPageNotPreviousRender() $this->assertStringNotContainsString('"component":"FirstPage"', $second); } - public function testInertiaStateDoesNotLeakBetweenRequests() + public function testInertiaStateDoesNotLeakBetweenRequests(): void { Config::set(['inertia.ssr.enabled' => true]); - $state1 = CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); + $state1 = InertiaState::current(); $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 + // Simulate a request boundary by clearing the current context state. $this->resetInertiaState(); - $state2 = CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); + $state2 = InertiaState::current(); $this->assertNotSame($state1, $state2); $this->assertNull($state2->ssrResponse); diff --git a/tests/Inertia/CoroutineIsolationTest.php b/tests/Inertia/CoroutineIsolationTest.php index 313277a17..9821c8d10 100644 --- a/tests/Inertia/CoroutineIsolationTest.php +++ b/tests/Inertia/CoroutineIsolationTest.php @@ -4,15 +4,62 @@ namespace Hypervel\Tests\Inertia; +use Closure; use Hypervel\Context\CoroutineContext; +use Hypervel\Context\RequestContext; +use Hypervel\Http\Request; use Hypervel\Inertia\InertiaState; +use Hypervel\Inertia\PropsResolver; use Hypervel\Inertia\ResponseFactory; +use Hypervel\Inertia\ScrollMetadata; +use Hypervel\Inertia\ScrollProp; use function Hypervel\Coroutine\parallel; class CoroutineIsolationTest extends TestCase { - public function testSharedPropsAreIsolatedBetweenCoroutines() + protected Closure $urlResolver; + + protected Closure $componentTransformer; + + protected function setUp(): void + { + parent::setUp(); + + $this->urlResolver = fn () => '/boot-url'; + $this->componentTransformer = fn (string $component) => "Boot/{$component}"; + + $factory = new ResponseFactory; + $factory->share('boot', 'shared'); + $factory->setRootView('boot-layout'); + $factory->version('boot-version'); + $factory->encryptHistory(); + $factory->resolveUrlUsing($this->urlResolver); + $factory->transformComponentUsing($this->componentTransformer); + $factory->disableSsr(); + $factory->withoutSsr('/private'); + } + + public function testBootConfigurationIsInheritedByRequestCoroutines(): void + { + [$state] = parallel([ + fn () => InertiaState::current(), + ]); + + $this->assertSame(['boot' => 'shared'], $state->sharedProps); + $this->assertSame('boot-layout', $state->rootView); + $this->assertSame('boot-version', $state->version); + $this->assertTrue($state->encryptHistory); + $this->assertSame($this->urlResolver, $state->urlResolver); + $this->assertSame($this->componentTransformer, $state->componentTransformer); + $this->assertTrue($state->ssrDisabled); + $this->assertSame(['/private'], $state->ssrExcludedPaths); + $this->assertSame([], $state->page); + $this->assertFalse($state->ssrDispatched); + $this->assertNull($state->ssrResponse); + } + + public function testSharedPropsAreIsolatedBetweenCoroutines(): void { $results = parallel([ function () { @@ -36,7 +83,7 @@ function () { $this->assertCount(2, $results); } - public function testRootViewIsIsolatedBetweenCoroutines() + public function testRootViewIsIsolatedBetweenCoroutines(): void { $results = parallel([ function () { @@ -44,14 +91,14 @@ function () { $factory->setRootView('layout-a'); usleep(1000); - return CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState)->rootView; + return InertiaState::current()->rootView; }, function () { $factory = new ResponseFactory; $factory->setRootView('layout-b'); usleep(1000); - return CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState)->rootView; + return InertiaState::current()->rootView; }, ]); @@ -59,7 +106,105 @@ function () { $this->assertContains('layout-b', $results); } - public function testInertiaStateIsDestroyedWhenCoroutineEnds() + public function testFlushSharedOnlyClearsTheCurrentRequest(): void + { + [$flushed, $sibling] = parallel([ + function () { + $factory = new ResponseFactory; + $factory->flushShared(); + usleep(1000); + + return $factory->getShared(); + }, + function () { + usleep(1000); + + return (new ResponseFactory)->getShared(); + }, + ]); + + $this->assertSame([], $flushed); + $this->assertSame(['boot' => 'shared'], $sibling); + + [$subsequent] = parallel([ + fn () => (new ResponseFactory)->getShared(), + ]); + + $this->assertSame(['boot' => 'shared'], $subsequent); + } + + public function testBootSharedScrollPropsResolveIndependentlyBetweenRequestCoroutines(): void + { + $scrollProp = new ScrollProp( + fn () => ['data' => [['id' => request()->query('id')]]], + 'data', + new ScrollMetadata('page', null, 2, 1), + ); + + (new ResponseFactory)->share('feed', $scrollProp); + CoroutineContext::copyToNonCoroutine([InertiaState::CONTEXT_KEY]); + + $resolve = fn (string $id): Closure => function () use ($id): array { + $request = Request::create("/?id={$id}"); + RequestContext::set($request); + + [$props, $metadata] = (new PropsResolver($request, 'TestComponent')) + ->resolve(InertiaState::current()->sharedProps, []); + + return [$props['feed']['data'][0]['id'], $metadata]; + }; + + [$first, $second] = parallel([ + $resolve('first'), + $resolve('second'), + ]); + + $this->assertSame('first', $first[0]); + $this->assertSame('second', $second[0]); + + foreach ([$first[1], $second[1]] as $metadata) { + $this->assertSame(['feed.data'], $metadata['mergeProps']); + $this->assertSame([ + 'feed' => [ + 'pageName' => 'page', + 'previousPage' => null, + 'nextPage' => 2, + 'currentPage' => 1, + 'reset' => false, + ], + ], $metadata['scrollProps']); + } + + $this->assertSame([], $scrollProp->appendsAtPaths()); + } + + public function testCopiedInertiaStateIsIndependent(): void + { + [$result] = parallel([ + function () { + $parent = InertiaState::current(); + $parent->sharedProps['parent'] = true; + + [$child] = parallel([ + function () { + $state = InertiaState::current(); + $state->sharedProps['child'] = true; + + return $state->sharedProps; + }, + ], copyContext: true); + + return [$parent->sharedProps, $child]; + }, + ]); + + [$parent, $child] = $result; + + $this->assertSame(['boot' => 'shared', 'parent' => true], $parent); + $this->assertSame(['boot' => 'shared', 'parent' => true, 'child' => true], $child); + } + + public function testInertiaStateIsDestroyedWhenCoroutineEnds(): void { // First parallel block: coroutine sets state then ends parallel([ @@ -72,10 +217,10 @@ function () { // Second parallel block: new coroutine should not see the state $results = parallel([ function () { - return CoroutineContext::get(InertiaState::CONTEXT_KEY); + return (new ResponseFactory)->getShared(); }, ]); - $this->assertNull($results[0]); + $this->assertArrayNotHasKey('key', $results[0]); } } diff --git a/tests/Inertia/DirectiveTest.php b/tests/Inertia/DirectiveTest.php index 87a928ca4..110356fa1 100644 --- a/tests/Inertia/DirectiveTest.php +++ b/tests/Inertia/DirectiveTest.php @@ -11,6 +11,8 @@ use Hypervel\Support\Facades\Config; use Hypervel\Tests\Inertia\Fixtures\FakeGateway; use Hypervel\View\Compilers\BladeCompiler; +use Hypervel\View\ViewException; +use JsonException; use Mockery as m; class DirectiveTest extends TestCase @@ -74,6 +76,30 @@ public function testInertiaDirectiveCanUseADifferentRootElementId(): void $this->assertSame($html, $this->renderView('@inertia("foo")', ['page' => self::EXAMPLE_PAGE_OBJECT])); } + public function testInertiaDirectivePreservesAZeroRootElementId(): void + { + Config::set(['inertia.ssr.enabled' => false]); + + $html = '
'; + + $this->assertSame($html, $this->renderView('@inertia("0")', ['page' => self::EXAMPLE_PAGE_OBJECT])); + } + + public function testInertiaDirectiveReportsPageEncodingFailures(): void + { + Config::set(['inertia.ssr.enabled' => false]); + + try { + $this->renderView('@inertia', ['page' => ['value' => "\xB1\x31"]]); + } catch (ViewException $exception) { + $this->assertInstanceOf(JsonException::class, $exception->getPrevious()); + + return; + } + + $this->fail('The unencodable page did not throw a view exception.'); + } + public function testInertiaHeadDirectiveRendersNothing(): void { Config::set(['inertia.ssr.enabled' => false]); diff --git a/tests/Inertia/ResponseFactoryTest.php b/tests/Inertia/ResponseFactoryTest.php index 159656352..0046996a8 100644 --- a/tests/Inertia/ResponseFactoryTest.php +++ b/tests/Inertia/ResponseFactoryTest.php @@ -182,6 +182,24 @@ public function testSharedDataCanBeSharedFromAnywhere(): void ]); } + public function testSharedDataTreatsEmptyAndZeroAsKeys(): void + { + Inertia::share([ + '' => 'empty', + '0' => 'zero', + 'foo' => 'bar', + ]); + + $this->assertSame('empty', Inertia::getShared('')); + $this->assertSame('zero', Inertia::getShared('0')); + $this->assertSame('fallback', Inertia::getShared('missing', 'fallback')); + $this->assertSame([ + '' => 'empty', + '0' => 'zero', + 'foo' => 'bar', + ], Inertia::getShared()); + } + public function testDotPropsAreMergedFromShared(): void { Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { diff --git a/tests/Inertia/ResponseTest.php b/tests/Inertia/ResponseTest.php index b227adce2..3f0de19c5 100644 --- a/tests/Inertia/ResponseTest.php +++ b/tests/Inertia/ResponseTest.php @@ -13,6 +13,7 @@ use Hypervel\Inertia\AlwaysProp; use Hypervel\Inertia\DeferProp; use Hypervel\Inertia\Inertia; +use Hypervel\Inertia\InertiaState; use Hypervel\Inertia\MergeProp; use Hypervel\Inertia\OptionalProp; use Hypervel\Inertia\ProvidesInertiaProperties; @@ -76,6 +77,24 @@ public function testServerResponse(): void $this->assertSame('
', $view->render()); } + public function testViewDataCannotOverrideTheInertiaPage(): void + { + $request = Request::create('/user/123', 'GET'); + $response = new Response('User/Edit', [], ['user' => ['name' => 'Jonathan']], 'app', '123'); + $response->withViewData('page', ['component' => 'Override']); + + /** @var BaseResponse $response */ + $response = $response->toResponse($request); + $view = $response->getOriginalContent(); + $page = $view->getData()['page']; + $rendered = $view->render(); + + $this->assertSame('User/Edit', $page['component']); + $this->assertSame($page, InertiaState::current()->page); + $this->assertStringContainsString('"component":"User\/Edit"', $rendered); + $this->assertStringNotContainsString('"component":"Override"', $rendered); + } + public function testServerResponseWithDeferredProp(): void { $request = Request::create('/user/123', 'GET'); @@ -1302,6 +1321,7 @@ public function testPropsCanBeAddedUsingTheWithMethod(): void $response->with(['foo' => 'bar', 'baz' => 'qux']) ->with(['quux' => 'corge']) + ->with(0, 'zero') ->with(new class implements ProvidesInertiaProperties { /** * @return Collection @@ -1320,6 +1340,7 @@ public function toInertiaProperties(RenderContext $context): iterable $this->assertSame('bar', $page['props']['foo']); $this->assertSame('qux', $page['props']['baz']); $this->assertSame('corge', $page['props']['quux']); + $this->assertSame('zero', $page['props'][0]); } public function testOncePropsAreAlwaysResolvedOnInitialPageLoad(): void From ea5b167bca0e5ff75c174368d4082acbe0b8677a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:16:46 +0000 Subject: [PATCH 2/9] fix(inertia): make prop resolution exact and request-safe Resolve each mutable ScrollProp through its own shallow copy so boot-shared values, sibling requests, and repeated logical paths cannot share cached results or merge metadata. Memoize legitimate null results while retaining retry behavior after exceptions. Preserve falsey helper, merge, once, reset, and diagnostic values; use strict protocol-list membership; normalize numeric keys only where string paths are required; and correct the affected return and owner annotations. Add regressions for nested providers, numeric-looking header collisions, metadata ownership, and null resolution. --- src/inertia/src/MergesProps.php | 4 +- src/inertia/src/PropsResolver.php | 32 ++++--- src/inertia/src/ScrollProp.php | 12 ++- src/inertia/src/Ssr/SsrRenderFailed.php | 2 +- src/inertia/src/helpers.php | 6 +- tests/Inertia/HelperTest.php | 6 ++ tests/Inertia/MergePropTest.php | 11 +++ tests/Inertia/PropsResolverTest.php | 117 +++++++++++++++++++++++- tests/Inertia/ScrollPropTest.php | 38 ++++++++ tests/Inertia/SsrRenderFailedTest.php | 21 +++++ 10 files changed, 228 insertions(+), 21 deletions(-) diff --git a/src/inertia/src/MergesProps.php b/src/inertia/src/MergesProps.php index e4610b6ec..1595ac35a 100644 --- a/src/inertia/src/MergesProps.php +++ b/src/inertia/src/MergesProps.php @@ -141,7 +141,7 @@ public function append(bool|string|array $path = true, ?string $matchOn = null): ), }; - if (is_string($path) && $matchOn) { + if (is_string($path) && $matchOn !== null && $matchOn !== '') { $this->matchOn([...$this->matchOn, "{$path}.{$matchOn}"]); } @@ -163,7 +163,7 @@ public function prepend(bool|string|array $path = true, ?string $matchOn = null) ), }; - if (is_string($path) && $matchOn) { + if (is_string($path) && $matchOn !== null && $matchOn !== '') { $this->matchOn([...$this->matchOn, "{$path}.{$matchOn}"]); } diff --git a/src/inertia/src/PropsResolver.php b/src/inertia/src/PropsResolver.php index 6644515e2..25da84569 100644 --- a/src/inertia/src/PropsResolver.php +++ b/src/inertia/src/PropsResolver.php @@ -150,7 +150,7 @@ public function __construct(Request $request, string $component) * * @param array $shared * @param array $props - * @return array{array, array} + * @return array{array, array} */ public function resolve(array $shared, array $props): array { @@ -166,7 +166,7 @@ public function resolve(array $shared, array $props): array * Resolve shared property providers and collect shared prop keys. * * @param array $shared - * @return array + * @return array */ protected function resolveSharedProps(array $shared): array { @@ -191,7 +191,7 @@ protected function resolveSharedProps(array $shared): array * Resolve ProvidesInertiaProperties instances into keyed props. * * @param array $props - * @return array + * @return array */ protected function resolvePropertyProviders(array $props): array { @@ -237,7 +237,7 @@ protected function buildMetadata(): array * Recursively resolve the props tree, collecting metadata along the way. * * @param array $props - * @return array + * @return array */ protected function resolveProps(array $props, string $prefix = '', bool $parentWasResolved = false): array { @@ -245,8 +245,11 @@ protected function resolveProps(array $props, string $prefix = '', bool $parentW $result = []; foreach ($props as $key => $value) { - $path = $prefix === '' ? $key : "{$prefix}.{$key}"; - $prop = $value; + $path = $prefix === '' ? (string) $key : "{$prefix}.{$key}"; + + // Shared scroll props may outlive a request, and resolution mutates them. + // Resolve each prop path through its own copy. + $prop = $value instanceof ScrollProp ? clone $value : $value; // On partial requests, we only include props that match the paths // specified in the request headers. AlwaysProp instances and the @@ -264,7 +267,7 @@ protected function resolveProps(array $props, string $prefix = '', bool $parentW $value = $this->resolveValue($prop, $path, $props); - if (in_array($path, $this->rescuedProps)) { + if (in_array($path, $this->rescuedProps, true)) { continue; } @@ -272,7 +275,7 @@ protected function resolveProps(array $props, string $prefix = '', bool $parentW // 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; + $prop = $value instanceof ScrollProp ? clone $value : $value; // Check again after unwrapping: the resolved prop type may // itself need to be excluded from the initial response. @@ -412,7 +415,7 @@ protected function wasAlreadyLoadedByClient(mixed $prop, string $path): bool return $prop instanceof Onceable && $prop->shouldResolveOnce() && ! $prop->shouldBeRefreshed() - && in_array($prop->getKey() ?? $path, $this->loadedOnceProps); + && in_array($prop->getKey() ?? $path, $this->loadedOnceProps, true); } /** @@ -509,7 +512,7 @@ protected function collectDeferredPropMetadata(string $path, Deferrable $prop): */ protected function collectMergeableMetadata(string $path, Mergeable $prop): void { - if (in_array($path, $this->resetProps)) { + if (in_array($path, $this->resetProps, true)) { return; } @@ -546,7 +549,7 @@ protected function collectScrollMetadata(string $path, ScrollProp $prop): void { $this->scrollProps[$path] = [ ...$prop->metadata(), - 'reset' => in_array($path, $this->resetProps), + 'reset' => in_array($path, $this->resetProps, true), ]; } @@ -697,6 +700,11 @@ protected function ensurePathIsTraversable(array &$props, string $dotKey): void */ protected function parseHeader(string $key): ?array { - return array_filter(explode(',', $this->request->header($key, ''))) ?: null; + $values = array_filter( + explode(',', $this->request->header($key, '')), + fn (string $value): bool => $value !== '', + ); + + return $values === [] ? null : $values; } } diff --git a/src/inertia/src/ScrollProp.php b/src/inertia/src/ScrollProp.php index 732928e75..9c5671412 100644 --- a/src/inertia/src/ScrollProp.php +++ b/src/inertia/src/ScrollProp.php @@ -37,6 +37,11 @@ class ScrollProp implements Deferrable, Mergeable */ protected $resolved; + /** + * Indicates if the property value has been resolved. + */ + protected bool $hasResolved = false; + /** * The wrapper key for the data array. */ @@ -122,10 +127,13 @@ public function metadata(): array */ public function __invoke(): mixed { - if (isset($this->resolved)) { + if ($this->hasResolved) { return $this->resolved; } - return $this->resolved = $this->resolveCallable($this->value); + $this->resolved = $this->resolveCallable($this->value); + $this->hasResolved = true; + + return $this->resolved; } } diff --git a/src/inertia/src/Ssr/SsrRenderFailed.php b/src/inertia/src/Ssr/SsrRenderFailed.php index affcd2faa..a33bad3a5 100644 --- a/src/inertia/src/Ssr/SsrRenderFailed.php +++ b/src/inertia/src/Ssr/SsrRenderFailed.php @@ -63,6 +63,6 @@ public function toArray(): array 'hint' => $this->hint, 'browser_api' => $this->browserApi, 'source_location' => $this->sourceLocation, - ]); + ], fn (mixed $value): bool => $value !== null); } } diff --git a/src/inertia/src/helpers.php b/src/inertia/src/helpers.php index b9eb4fe04..c57c797ae 100644 --- a/src/inertia/src/helpers.php +++ b/src/inertia/src/helpers.php @@ -19,11 +19,11 @@ function inertia(?string $component = null, array|Arrayable $props = []): Respon { $instance = Inertia::getFacadeRoot(); - if ($component) { - return $instance->render($component, $props); + if ($component === null) { + return $instance; } - return $instance; + return $instance->render($component, $props); } } diff --git a/tests/Inertia/HelperTest.php b/tests/Inertia/HelperTest.php index 6a156f716..2677f711f 100644 --- a/tests/Inertia/HelperTest.php +++ b/tests/Inertia/HelperTest.php @@ -20,6 +20,12 @@ public function testTheHelperFunctionReturnsAResponseInstance(): void $this->assertInstanceOf(Response::class, inertia('User/Edit', ['user' => ['name' => 'Jonathan']])); } + public function testTheHelperFunctionDelegatesEveryStringComponent(): void + { + $this->assertInstanceOf(Response::class, inertia('')); + $this->assertInstanceOf(Response::class, inertia('0')); + } + public function testTheInstanceIsTheSameAsTheFacadeInstance(): void { Inertia::share('key', 'value'); diff --git a/tests/Inertia/MergePropTest.php b/tests/Inertia/MergePropTest.php index bb295e9ec..43db3a2b3 100644 --- a/tests/Inertia/MergePropTest.php +++ b/tests/Inertia/MergePropTest.php @@ -107,6 +107,17 @@ public function testPrependsWithNestedMergePathsAndMatchOn(): void $this->assertSame(['data.id'], $mergeProp->matchesOn()); } + public function testNestedMergePathsPreserveAZeroMatchKey(): void + { + $appended = (new MergeProp([]))->append('data', '0'); + $prepended = (new MergeProp([]))->prepend('data', '0'); + $empty = (new MergeProp([]))->append('data', ''); + + $this->assertSame(['data.0'], $appended->matchesOn()); + $this->assertSame(['data.0'], $prepended->matchesOn()); + $this->assertSame([], $empty->matchesOn()); + } + public function testAppendWithNestedMergePathsAsArray(): void { $mergeProp = (new MergeProp([]))->append(['data', 'items']); diff --git a/tests/Inertia/PropsResolverTest.php b/tests/Inertia/PropsResolverTest.php index e3454a638..b5dd8cc3e 100644 --- a/tests/Inertia/PropsResolverTest.php +++ b/tests/Inertia/PropsResolverTest.php @@ -546,6 +546,38 @@ public function testNestedMergePropMetadataIsSuppressedByResetHeader(): void $this->assertArrayNotHasKey('mergeProps', $page); } + public function testPartialAndResetHeadersPreserveAZeroPropPath(): void + { + $request = $this->makePartialRequest('0'); + $request->headers->add(['X-Inertia-Reset' => '0']); + + $page = $this->makePage($request, [ + '0' => new MergeProp([['id' => 1]]), + 'other' => 'value', + ]); + + $this->assertSame([['id' => 1]], $page['props'][0]); + $this->assertArrayNotHasKey('other', $page['props']); + $this->assertArrayNotHasKey('mergeProps', $page); + } + + public function testResetHeaderDoesNotCoerceNumericPropPaths(): void + { + $request = $this->makePartialRequest('0'); + $request->headers->add(['X-Inertia-Reset' => '0.0']); + + $page = $this->makePage($request, [ + '0' => new ScrollProp( + ['data' => [['id' => 1]]], + 'data', + $this->makeScrollMetadata(), + ), + ]); + + $this->assertSame(['0.data'], $page['mergeProps']); + $this->assertFalse($page['scrollProps'][0]['reset']); + } + public function testNestedOncePropMetadataIsCollected(): void { $page = $this->makePage(Request::create('/'), [ @@ -586,6 +618,34 @@ public function testNestedOncePropIsExcludedWhenAlreadyLoaded(): void $this->assertSame(['config.locale' => ['prop' => 'config.locale', 'expiresAt' => null]], $page['onceProps']); } + public function testOnceHeaderPreservesAZeroCustomKey(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => '0']); + + $page = $this->makePage($request, [ + 'locale' => Inertia::once(fn () => 'en')->as('0'), + ]); + + $this->assertArrayNotHasKey('locale', $page['props']); + $this->assertSame(['0' => ['prop' => 'locale', 'expiresAt' => null]], $page['onceProps']); + } + + public function testOnceHeaderDoesNotCoerceNumericCustomKeys(): void + { + $request = Request::create('/'); + $request->headers->add(['X-Inertia' => 'true']); + $request->headers->add(['X-Inertia-Except-Once-Props' => '0.0']); + + $page = $this->makePage($request, [ + 'locale' => Inertia::once(fn () => 'en')->as('0'), + ]); + + $this->assertSame('en', $page['props']['locale']); + $this->assertSame(['0' => ['prop' => 'locale', 'expiresAt' => null]], $page['onceProps']); + } + public function testNestedOnceMetadataIsCollectedOnExactPartialRequest(): void { $page = $this->makePage($this->makePartialRequest('config.locale'), [ @@ -633,6 +693,61 @@ public function testNestedScrollPropMetadataIsCollected(): void ], $page['scrollProps']); } + public function testSharedScrollPropResolvesIndependentlyAtEachPath(): void + { + $callCount = 0; + $scrollProp = new ScrollProp( + function () use (&$callCount) { + return ['data' => [['id' => ++$callCount]]]; + }, + 'data', + fn () => $this->makeScrollMetadata(), + ); + + $page = $this->makePage(Request::create('/'), [ + 'first' => $scrollProp, + 'second' => $scrollProp, + ]); + + $this->assertSame(2, $callCount); + $this->assertSame([['id' => 1]], $page['props']['first']['data']); + $this->assertSame([['id' => 2]], $page['props']['second']['data']); + $this->assertSame(['first.data', 'second.data'], $page['mergeProps']); + $this->assertSame(['first', 'second'], array_keys($page['scrollProps'])); + } + + public function testPropertyProviderScrollPropResolvesWithoutMutatingItsSource(): void + { + $callCount = 0; + $scrollProp = new ScrollProp( + function () use (&$callCount) { + ++$callCount; + + return ['data' => [['id' => 1]]]; + }, + 'data', + fn () => $this->makeScrollMetadata(), + ); + + $provider = new class($scrollProp) implements ProvidesInertiaProperties { + public function __construct(private readonly ScrollProp $scrollProp) + { + } + + public function toInertiaProperties(RenderContext $context): iterable + { + return ['feed' => $this->scrollProp]; + } + }; + + $page = $this->makePage(Request::create('/'), [$provider]); + + $this->assertSame(1, $callCount); + $this->assertSame([['id' => 1]], $page['props']['feed']['data']); + $this->assertSame(['feed.data'], $page['mergeProps']); + $this->assertSame([], $scrollProp->appendsAtPaths()); + } + public function testNestedDeferredScrollPropIsExcludedFromInitialLoad(): void { $page = $this->makePage(Request::create('/'), [ @@ -1049,7 +1164,7 @@ public function testArraysMatchingCallableSyntaxAreNotInvoked(): void /** * Resolve the given props through the Inertia response and return the page data. * - * @param array $props + * @param array $props * @return array */ protected function makePage(Request $request, array $props): array diff --git a/tests/Inertia/ScrollPropTest.php b/tests/Inertia/ScrollPropTest.php index 21e699603..12247889a 100644 --- a/tests/Inertia/ScrollPropTest.php +++ b/tests/Inertia/ScrollPropTest.php @@ -13,6 +13,7 @@ use Hypervel\Inertia\ScrollProp; use Hypervel\Inertia\Support\Header; use Hypervel\Tests\Inertia\Fixtures\User; +use RuntimeException; class ScrollPropTest extends TestCase { @@ -173,6 +174,43 @@ public function testScrollPropValueIsResolvedOnlyOnce(): void $this->assertEquals(['item1', 'item2', 'item3'], $value1); } + public function testNullScrollPropValueIsResolvedOnlyOnce(): void + { + $callCount = 0; + $scrollProp = new ScrollProp(function () use (&$callCount) { + ++$callCount; + + return null; + }); + + $this->assertNull($scrollProp()); + $this->assertNull($scrollProp()); + $this->assertSame(1, $callCount); + } + + public function testScrollPropRetriesAfterResolutionFailure(): void + { + $callCount = 0; + $scrollProp = new ScrollProp(function () use (&$callCount) { + if (++$callCount === 1) { + throw new RuntimeException('Failed to resolve scroll prop.'); + } + + return ['item1']; + }); + + try { + $scrollProp(); + $this->fail('The first resolution should fail.'); + } catch (RuntimeException $exception) { + $this->assertSame('Failed to resolve scroll prop.', $exception->getMessage()); + } + + $this->assertSame(['item1'], $scrollProp()); + $this->assertSame(['item1'], $scrollProp()); + $this->assertSame(2, $callCount); + } + public function testStringFunctionNamesAreNotInvoked(): void { $scrollProp = new ScrollProp('date'); diff --git a/tests/Inertia/SsrRenderFailedTest.php b/tests/Inertia/SsrRenderFailedTest.php index 654c100d2..332437d5d 100644 --- a/tests/Inertia/SsrRenderFailedTest.php +++ b/tests/Inertia/SsrRenderFailedTest.php @@ -75,4 +75,25 @@ public function testToArrayExcludesNullValues(): void $this->assertArrayNotHasKey('browser_api', $array); $this->assertArrayNotHasKey('source_location', $array); } + + public function testToArrayPreservesEmptyAndZeroDiagnosticValues(): void + { + $event = new SsrRenderFailed( + page: ['component' => '0', 'url' => '0'], + error: '0', + hint: '', + browserApi: '0', + sourceLocation: '0', + ); + + $this->assertSame([ + 'component' => '0', + 'url' => '0', + 'error' => '0', + 'type' => 'unknown', + 'hint' => '', + 'browser_api' => '0', + 'source_location' => '0', + ], $event->toArray()); + } } From f4b90c41246617df41133948b0f0dba664822dde Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:16:57 +0000 Subject: [PATCH 3/9] fix(inertia): preserve response protocol semantics Attach the current asset version to mismatch redirects, append and case-insensitively deduplicate Vary on the response actually returned, and preserve existing cache variance through replacement responses. Treat only exact empty-string content as an empty response so zero, streamed, and binary bodies remain intact. Preserve zero-valued error-bag names and use strict redirect-method matching, with coverage for normal responses and every replacement path. --- src/inertia/src/Middleware.php | 36 +++++-- .../src/Middleware/EnsureGetOnRedirect.php | 2 +- tests/Inertia/MiddlewareTest.php | 93 ++++++++++++++++++- 3 files changed, 120 insertions(+), 11 deletions(-) diff --git a/src/inertia/src/Middleware.php b/src/inertia/src/Middleware.php index 125fb78cc..38a257411 100644 --- a/src/inertia/src/Middleware.php +++ b/src/inertia/src/Middleware.php @@ -141,7 +141,7 @@ public function handle(Request $request, Closure $next): Response Inertia::setRootView($this->rootView($request)); - if ($urlResolver = $this->urlResolver()) { + if (($urlResolver = $this->urlResolver()) !== null) { Inertia::resolveUrlUsing($urlResolver); } @@ -152,13 +152,14 @@ public function handle(Request $request, Closure $next): Response } $response = $next($request); - $response->headers->set('Vary', Header::INERTIA); if ($isRedirect = $response->isRedirect()) { $this->reflash($request); } if (! $request->header(Header::INERTIA)) { + $this->addInertiaVaryHeader($response); + return $response; } @@ -166,11 +167,11 @@ public function handle(Request $request, Closure $next): Response $response = $this->onVersionChange($request, $response); } - if ($response->isOk() && empty($response->getContent())) { + if ($response->isOk() && $response->getContent() === '') { $response = $this->onEmptyResponse($request, $response); } - if ($response->getStatusCode() === 302 && in_array($request->method(), ['PUT', 'PATCH', 'DELETE'])) { + if ($response->getStatusCode() === 302 && in_array($request->method(), ['PUT', 'PATCH', 'DELETE'], true)) { $response->setStatusCode(303); } @@ -178,9 +179,25 @@ public function handle(Request $request, Closure $next): Response $response = $this->onRedirectWithFragment($request, $response); } + $this->addInertiaVaryHeader($response); + return $response; } + /** + * Add the Inertia header to the response's Vary list. + */ + protected function addInertiaVaryHeader(Response $response): void + { + foreach ($response->getVary() as $header) { + if (strcasecmp($header, Header::INERTIA) === 0) { + return; + } + } + + $response->setVary(Header::INERTIA, false); + } + /** * Determine if the redirect response contains a URL fragment. */ @@ -228,7 +245,10 @@ public function onVersionChange(Request $request, Response $response): Response $session->reflash(); } - return Inertia::location($request->fullUrl()); + $response = Inertia::location($request->fullUrl()); + $response->headers->set(Header::VERSION, Inertia::getVersion()); + + return $response; } /** @@ -248,8 +268,10 @@ public function resolveValidationErrors(Request $request): object 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')]; + $errorBag = $request->header(Header::ERROR_BAG); + + if ($bags->has('default') && $errorBag !== null && $errorBag !== '') { + return [$errorBag => $bags->get('default')]; } if ($bags->has('default')) { diff --git a/src/inertia/src/Middleware/EnsureGetOnRedirect.php b/src/inertia/src/Middleware/EnsureGetOnRedirect.php index d28567fc5..0f70d254d 100644 --- a/src/inertia/src/Middleware/EnsureGetOnRedirect.php +++ b/src/inertia/src/Middleware/EnsureGetOnRedirect.php @@ -22,7 +22,7 @@ public function handle(Request $request, Closure $next): Response if ($response->getStatusCode() === 302 && $request->header(Header::INERTIA) - && in_array($request->method(), ['PUT', 'PATCH', 'DELETE']) + && in_array($request->method(), ['PUT', 'PATCH', 'DELETE'], true) ) { $response->setStatusCode(303); } diff --git a/tests/Inertia/MiddlewareTest.php b/tests/Inertia/MiddlewareTest.php index 8f922c998..845371256 100644 --- a/tests/Inertia/MiddlewareTest.php +++ b/tests/Inertia/MiddlewareTest.php @@ -10,6 +10,7 @@ use Hypervel\Inertia\Inertia; use Hypervel\Inertia\Middleware; use Hypervel\Inertia\Ssr\HttpGateway; +use Hypervel\Inertia\Support\Header; use Hypervel\Routing\Route as RouteInstance; use Hypervel\Session\Middleware\StartSession; use Hypervel\Support\Facades\Route; @@ -22,6 +23,9 @@ use Hypervel\Tests\Inertia\Fixtures\WithAllErrorsMiddleware; use LogicException; use PHPUnit\Framework\Attributes\After; +use Symfony\Component\HttpFoundation\BinaryFileResponse; +use Symfony\Component\HttpFoundation\Response as SymfonyResponse; +use Symfony\Component\HttpFoundation\StreamedResponse; class MiddlewareTest extends TestCase { @@ -47,6 +51,7 @@ public function testNoResponseValueByDefaultMeansAutomaticallyRedirectingBackFor $response->assertRedirect('/foo'); $response->assertStatus(303); + $response->assertHeader('Vary', 'X-Inertia'); $this->assertTrue($fooCalled); } @@ -82,9 +87,69 @@ public function testNoResponseMeansNoResponseForNonInertiaRequests(): void ]); $response->assertNoContent(200); + $response->assertHeader('Vary', 'X-Inertia'); $this->assertTrue($fooCalled); } + public function testNonInertiaResponsesPreserveExistingVaryHeaders(): void + { + $request = Request::create('/'); + $response = new SymfonyResponse('content', 200, ['Vary' => 'Accept-Encoding']); + + $result = (new Middleware)->handle($request, fn () => $response); + + $this->assertSame($response, $result); + $this->assertSame(['Accept-Encoding', Header::INERTIA], $result->getVary()); + } + + public function testInertiaVaryHeaderIsDeduplicatedCaseInsensitively(): void + { + $request = Request::create('/'); + $request->headers->set(Header::INERTIA, 'true'); + $response = new SymfonyResponse('content', 200, ['Vary' => 'x-inertia, Accept-Encoding']); + + $result = (new Middleware)->handle($request, fn () => $response); + + $this->assertSame($response, $result); + $this->assertSame(['x-inertia', 'Accept-Encoding'], $result->getVary()); + } + + public function testZeroContentIsNotTreatedAsAnEmptyResponse(): void + { + $request = Request::create('/'); + $request->headers->set(Header::INERTIA, 'true'); + $response = new SymfonyResponse('0'); + + $result = (new Middleware)->handle($request, fn () => $response); + + $this->assertSame($response, $result); + $this->assertSame('0', $result->getContent()); + } + + public function testStreamedResponsesAreNotTreatedAsEmptyResponses(): void + { + $request = Request::create('/'); + $request->headers->set(Header::INERTIA, 'true'); + $response = new StreamedResponse(fn () => print 'content'); + + $result = (new Middleware)->handle($request, fn () => $response); + + $this->assertSame($response, $result); + $this->assertFalse($result->getContent()); + } + + public function testBinaryResponsesAreNotTreatedAsEmptyResponses(): void + { + $request = Request::create('/'); + $request->headers->set(Header::INERTIA, 'true'); + $response = new BinaryFileResponse(__FILE__); + + $result = (new Middleware)->handle($request, fn () => $response); + + $this->assertSame($response, $result); + $this->assertFalse($result->getContent()); + } + public function testTheVersionIsOptional(): void { $this->prepareMockEndpoint(); @@ -94,6 +159,7 @@ public function testTheVersionIsOptional(): void ]); $response->assertSuccessful(); + $response->assertHeader('Vary', 'X-Inertia'); $response->assertJson(['component' => 'User/Edit']); } @@ -134,6 +200,8 @@ public function testItWillInstructInertiaToReloadOnAVersionMismatch(): void $response->assertStatus(409); $response->assertHeader('X-Inertia-Location', $this->baseUrl); + $response->assertHeader('X-Inertia-Version', '1234'); + $response->assertHeader('Vary', 'X-Inertia'); self::assertEmpty($response->getContent()); } @@ -265,6 +333,22 @@ public function testValidationErrorsAreScopedToErrorBagHeader(): void $this->withoutExceptionHandling()->get('/', ['X-Inertia-Error-Bag' => 'example']); } + public function testValidationErrorsPreserveAZeroErrorBagHeader(): void + { + Session::put('errors', (new ViewErrorBag)->put('default', new MessageBag([ + 'name' => 'The name field is required.', + ]))); + + Route::middleware([StartSession::class, ExampleMiddleware::class])->get('/', function () { + $errors = Inertia::getShared('errors')(); + + $this->assertIsObject($errors); + $this->assertSame('The name field is required.', $errors->{'0'}->name); + }); + + $this->withoutExceptionHandling()->get('/', ['X-Inertia-Error-Bag' => '0']); + } + public function testMiddlewareCanChangeTheRootViewViaAProperty(): void { $this->prepareMockEndpoint(null, [], new class extends Middleware { @@ -440,6 +524,7 @@ public function testRedirectWithHashFragmentReturns409ForInertiaRequests(): void $response->assertStatus(409); $response->assertHeader('X-Inertia-Redirect', $this->baseUrl . '/article#section'); + $response->assertHeader('Vary', 'X-Inertia'); self::assertEmpty($response->getContent()); } @@ -503,14 +588,16 @@ public function testMiddlewareRegistersSsrExceptPaths(): void Route::middleware(StartSession::class)->get('/admin/dashboard', function (Request $request) use ($middleware) { return $middleware->handle($request, function ($request) { + $this->assertContains('admin/*', app(HttpGateway::class)->getExcludedPaths()); + $this->assertContains('nova/*', app(HttpGateway::class)->getExcludedPaths()); + return Inertia::render('Admin/Dashboard')->toResponse($request); }); }); - $this->get('/admin/dashboard'); + $response = $this->withoutExceptionHandling()->get('/admin/dashboard'); - $this->assertContains('admin/*', app(HttpGateway::class)->getExcludedPaths()); - $this->assertContains('nova/*', app(HttpGateway::class)->getExcludedPaths()); + $response->assertSuccessful(); } public function testVersionIsCachedForWorkerLifetime(): void From 8bf781f99d4a2867118cc9eeb82737ca65c3a0bc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:17:08 +0000 Subject: [PATCH 4/9] fix(inertia): harden SSR transport lifecycle Give the gateway contract and concrete transport one worker identity, retain the reusable cookie-free client, and singleton-reuse the existing page finder cache. Add configurable hot URLs with checked publication fallback and normalize provider container resolution. Validate the exact SSR success shape, normalize remote error metadata, and limit worker backoff to connection and malformed-transport failures while clearing it on proven reachability. Add handler-independent health and shutdown transport behavior with comprehensive success, failure, backoff, identity, cache, and configuration coverage. --- src/inertia/config/inertia.php | 10 +- src/inertia/src/InertiaServiceProvider.php | 13 +- src/inertia/src/Ssr/HttpGateway.php | 122 ++++++-- src/inertia/src/Ssr/SsrException.php | 10 +- tests/Inertia/HttpGatewayTest.php | 295 +++++++++++++++++-- tests/Inertia/InertiaServiceProviderTest.php | 61 ++++ 6 files changed, 451 insertions(+), 60 deletions(-) diff --git a/src/inertia/config/inertia.php b/src/inertia/config/inertia.php index 14b02a6ea..c3f978bf8 100644 --- a/src/inertia/config/inertia.php +++ b/src/inertia/config/inertia.php @@ -8,7 +8,7 @@ | Server Side Rendering |-------------------------------------------------------------------------- | - | These options configures if and how Inertia uses Server Side Rendering + | These options configure if and how Inertia uses Server Side Rendering | to pre-render the initial visits made to your application's pages. | | You can specify a custom SSR bundle path, or omit it to let Inertia @@ -29,6 +29,8 @@ 'url' => env('INERTIA_SSR_URL', 'http://127.0.0.1:13714'), + 'hot_url' => env('INERTIA_SSR_HOT_URL'), + 'ensure_bundle_exists' => (bool) env('INERTIA_SSR_ENSURE_BUNDLE_EXISTS', true), // 'bundle' => base_path('bootstrap/ssr/ssr.mjs'), @@ -52,9 +54,9 @@ | 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. + | When the SSR server cannot be reached or returns a malformed response, + | the worker will skip SSR for this many seconds before retrying. This + | prevents every coroutine from flooding an unavailable server. | */ diff --git a/src/inertia/src/InertiaServiceProvider.php b/src/inertia/src/InertiaServiceProvider.php index d65b0ff75..134699199 100644 --- a/src/inertia/src/InertiaServiceProvider.php +++ b/src/inertia/src/InertiaServiceProvider.php @@ -25,7 +25,10 @@ class InertiaServiceProvider extends ServiceProvider */ public function register(): void { - $this->app->singleton(Gateway::class, HttpGateway::class); + $this->app->singleton( + Gateway::class, + fn ($app) => $app->make(HttpGateway::class), + ); $this->mergeConfigFrom( __DIR__ . '/../config/inertia.php', @@ -40,13 +43,13 @@ public function register(): void $this->registerTestingMacros(); $this->registerMiddleware(); - $this->app->bind('inertia.view-finder', function ($app) { + $this->app->singleton('inertia.view-finder', function ($app) { $config = $app->make('config'); return new FileViewFinder( - $app['files'], + $app->make('files'), $config->array('inertia.pages.paths'), - $config->array('inertia.pages.extensions') + $config->array('inertia.pages.extensions'), ); }); } @@ -174,7 +177,7 @@ protected function registerTestingMacros(): void */ protected function registerMiddleware(): void { - $this->app['router']->aliasMiddleware( + $this->app->make('router')->aliasMiddleware( 'inertia.encrypt', EncryptHistoryMiddleware::class ); diff --git a/src/inertia/src/Ssr/HttpGateway.php b/src/inertia/src/Ssr/HttpGateway.php index f000ee15b..e3da39239 100644 --- a/src/inertia/src/Ssr/HttpGateway.php +++ b/src/inertia/src/Ssr/HttpGateway.php @@ -8,7 +8,6 @@ use GuzzleHttp\Client; use GuzzleHttp\ClientInterface; use GuzzleHttp\Exception\TransferException; -use Hypervel\Context\CoroutineContext; use Hypervel\Foundation\Http\Middleware\Concerns\ExcludesPaths; use Hypervel\Http\Request; use Hypervel\Inertia\InertiaState; @@ -25,8 +24,8 @@ class HttpGateway implements DisablesSsr, ExcludesSsrPaths, Gateway, HasHealthCh /** * The time until which SSR is considered unavailable for this worker. * - * Used as a circuit breaker to avoid flooding a dead SSR server - * with requests. Reset after the backoff period expires. + * Used to avoid flooding an unavailable SSR server with requests. + * Cleared as soon as the render transport responds again. */ private static ?float $ssrUnavailableUntil = null; @@ -48,7 +47,7 @@ class HttpGateway implements DisablesSsr, ExcludesSsrPaths, Gateway, HasHealthCh */ private function state(): InertiaState { - return CoroutineContext::getOrSet(InertiaState::CONTEXT_KEY, fn () => new InertiaState); + return InertiaState::current(); } /** @@ -105,35 +104,44 @@ public function dispatch(array $page, ?Request $request = null): ?Response ? $this->getHotUrl('/__inertia_ssr') : $this->getProductionUrl('/render'); + if ($url === null) { + return null; + } + try { $response = $this->ssrClient()->request('POST', $url, [ 'json' => $page, ]); + self::$ssrUnavailableUntil = null; if ($response->getStatusCode() >= 400) { $decoded = json_decode((string) $response->getBody(), true); + $structured = is_array($decoded); + + if (! $structured) { + $this->armTransportBackoff(); + } - $this->handleSsrFailure($page, is_array($decoded) ? $decoded : null); + $this->handleSsrFailure($page, $structured ? $decoded : null); return null; } $data = json_decode((string) $response->getBody(), true); - if (! $data) { + if (! $this->isValidSsrResponse($data)) { + $this->armTransportBackoff(); + $this->handleSsrFailure($page, ['error' => 'Invalid SSR response.']); + return null; } - // SSR succeeded — clear any previous backoff - self::$ssrUnavailableUntil = null; - return new Response( - implode("\n", $data['head'] ?? []), - $data['body'] ?? '' + implode("\n", $data['head']), + $data['body'], ); - } catch (SsrException $e) { - throw $e; } catch (TransferException $e) { + $this->armTransportBackoff(); $this->handleSsrFailure($page, [ 'error' => $e->getMessage(), 'type' => 'connection', @@ -178,8 +186,6 @@ public function getExcludedPaths(): array /** * Handle an SSR rendering failure. * - * Sets the circuit breaker backoff and dispatches a failure event. - * * @param array $page * @param null|array $error * @@ -187,17 +193,14 @@ public function getExcludedPaths(): array */ protected function handleSsrFailure(array $page, ?array $error): void { - // 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, + error: $this->stringOrNull($error['error'] ?? null) ?? 'Unknown SSR error', + type: SsrErrorType::fromString($this->stringOrNull($error['type'] ?? null)), + hint: $this->stringOrNull($error['hint'] ?? null), + browserApi: $this->stringOrNull($error['browserApi'] ?? null), + stack: $this->stringOrNull($error['stack'] ?? null), + sourceLocation: $this->stringOrNull($error['sourceLocation'] ?? null), ); // Dispatch the already-built event directly (avoids double construction) @@ -214,7 +217,7 @@ protected function handleSsrFailure(array $page, ?array $error): void */ protected function ssrIsEnabled(Request $request): bool { - // Circuit breaker: skip SSR if recently failed + // Skip SSR while transport backoff is active. if (self::$ssrUnavailableUntil !== null && microtime(true) < self::$ssrUnavailableUntil) { return false; } @@ -242,6 +245,22 @@ public function isHealthy(): bool } } + /** + * Shut down the SSR server. + * + * @throws TransferException + */ + public function shutdown(): bool + { + $response = $this->ssrClient()->request( + 'GET', + $this->getProductionUrl('/shutdown'), + ); + + return $response->getStatusCode() >= 200 + && $response->getStatusCode() < 300; + } + /** * Determine if the bundle existence should be ensured. */ @@ -272,9 +291,58 @@ public function getProductionUrl(string $path = '/'): string /** * Get the Vite hot SSR URL. */ - protected function getHotUrl(string $path = '/'): string + protected function getHotUrl(string $path = '/'): ?string + { + $baseUrl = (string) config('inertia.ssr.hot_url'); + + if ($baseUrl === '') { + $baseUrl = @file_get_contents(Vite::hotFile()); + + if ($baseUrl === false) { + return null; + } + } + + return rtrim(trim($baseUrl), '/') . Str::start($path, '/'); + } + + /** + * Determine if the decoded SSR response has the expected shape. + */ + protected function isValidSsrResponse(mixed $data): bool + { + if (! is_array($data) + || ! isset($data['head'], $data['body']) + || ! is_array($data['head']) + || ! is_string($data['body']) + ) { + return false; + } + + foreach ($data['head'] as $head) { + if (! is_string($head)) { + return false; + } + } + + return true; + } + + /** + * Activate SSR transport backoff. + */ + private function armTransportBackoff(): void + { + self::$ssrUnavailableUntil = microtime(true) + + (float) config('inertia.ssr.backoff', 5.0); + } + + /** + * Return the value when it is a string. + */ + private function stringOrNull(mixed $value): ?string { - return rtrim(file_get_contents(Vite::hotFile())) . $path; + return is_string($value) ? $value : null; } /** diff --git a/src/inertia/src/Ssr/SsrException.php b/src/inertia/src/Ssr/SsrException.php index e89242d08..8c6bae31d 100644 --- a/src/inertia/src/Ssr/SsrException.php +++ b/src/inertia/src/Ssr/SsrException.php @@ -8,6 +8,11 @@ class SsrException extends Exception { + /** + * The SSR render failed event containing error details. + */ + public ?SsrRenderFailed $event = null; + /** * Create a new SSR exception from a render failure event. */ @@ -29,11 +34,6 @@ public static function fromEvent(SsrRenderFailed $event): self return $exception; } - /** - * The SSR render failed event containing error details. - */ - public ?SsrRenderFailed $event = null; - /** * Get the component that failed to render. */ diff --git a/tests/Inertia/HttpGatewayTest.php b/tests/Inertia/HttpGatewayTest.php index f4930f897..52253a095 100644 --- a/tests/Inertia/HttpGatewayTest.php +++ b/tests/Inertia/HttpGatewayTest.php @@ -16,20 +16,21 @@ use Hypervel\Inertia\Ssr\SsrException; use Hypervel\Inertia\Ssr\SsrRenderFailed; use Hypervel\Support\Facades\Event; +use Hypervel\Support\Facades\Vite; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionMethod; +use ReflectionProperty; +use stdClass; class HttpGatewayTest extends TestCase { protected HttpGateway $gateway; - protected string $renderUrl; - protected function setUp(): void { parent::setUp(); $this->gateway = app(HttpGateway::class); - $this->renderUrl = $this->gateway->getProductionUrl('/render'); } protected function tearDown(): void @@ -146,7 +147,8 @@ public function testItReturnsNullWhenTheHttpRequestFails(): void $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); } - public function testItReturnsNullWhenInvalidJsonIsReturned(): void + #[DataProvider('malformedSsrResponses')] + public function testItRejectsMalformedSsrResponses(string $body): void { config([ 'inertia.ssr.enabled' => true, @@ -154,12 +156,59 @@ public function testItReturnsNullWhenInvalidJsonIsReturned(): void ]); $this->mockSsrClient([ - new GuzzleResponse(200, [], 'invalid json'), + new GuzzleResponse(200, [], $body), ]); $this->assertNull($this->gateway->dispatch(['page' => self::EXAMPLE_PAGE_OBJECT])); } + /** + * @return array + */ + public static function malformedSsrResponses(): array + { + return [ + 'invalid JSON' => ['invalid json'], + 'scalar JSON' => [json_encode('invalid')], + 'empty object' => [json_encode((object) [])], + 'missing head' => [json_encode(['body' => '
SSR
'])], + 'missing body' => [json_encode(['head' => []])], + 'non-array head' => [json_encode(['head' => 'SSR', 'body' => '
SSR
'])], + 'non-string head entry' => [json_encode(['head' => [null], 'body' => '
SSR
'])], + 'non-string body' => [json_encode(['head' => [], 'body' => []])], + ]; + } + + public function testMalformedSuccessDispatchesFailureAndHonorsThrowOnError(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.throw_on_error' => true, + ]); + + $mock = $this->mockSsrClient([ + new GuzzleResponse(200, [], json_encode(['head' => [], 'body' => []])), + new GuzzleResponse(200, [], json_encode(['head' => [], 'body' => '
SSR
'])), + ]); + + try { + $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + $this->fail('The malformed SSR response did not throw an exception.'); + } catch (SsrException $exception) { + $this->assertSame('Invalid SSR response.', $exception->event?->error); + } + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertSame(1, $mock->count()); + Event::assertDispatched( + SsrRenderFailed::class, + fn (SsrRenderFailed $event): bool => $event->error === 'Invalid SSR response.', + ); + } + public function testHealthCheckTheSsrServer(): void { $this->mockSsrClient([ @@ -173,11 +222,33 @@ public function testHealthCheckTheSsrServer(): void $this->assertFalse($this->gateway->isHealthy()); } + public function testShutdownReportsTheSsrServerResponse(): void + { + $this->mockSsrClient([ + new GuzzleResponse(200), + new GuzzleResponse(500), + ]); + + $this->assertTrue($this->gateway->shutdown()); + $this->assertFalse($this->gateway->shutdown()); + } + + public function testShutdownPreservesTransportFailures(): void + { + $this->mockSsrClient([ + new ConnectException('Connection closed', new GuzzleRequest('GET', '/shutdown')), + ]); + + $this->expectException(ConnectException::class); + + $this->gateway->shutdown(); + } + public function testItUsesViteHotUrlWhenRunningHot(): void { config(['inertia.ssr.enabled' => true]); - $this->createHotFile('http://localhost:5173'); + $this->createHotFile("http://localhost:5173/\n"); $mock = $this->mockSsrClient([ new GuzzleResponse(200, [], json_encode([ @@ -194,7 +265,68 @@ public function testItUsesViteHotUrlWhenRunningHot(): void // Verify the request was sent to the hot URL $lastRequest = $mock->getLastRequest(); - $this->assertStringContainsString('localhost:5173', (string) $lastRequest->getUri()); + $this->assertSame('http://localhost:5173/__inertia_ssr', (string) $lastRequest->getUri()); + } + + public function testItPrefersTheConfiguredHotUrl(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.hot_url' => 'http://localhost:4173/base/', + ]); + + $this->createHotFile('http://localhost:5173'); + + $mock = $this->mockSsrClient([ + new GuzzleResponse(200, [], json_encode([ + 'head' => [], + 'body' => '
Hot Response
', + ])), + ]); + + $this->assertNotNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertSame( + 'http://localhost:4173/base/__inertia_ssr', + (string) $mock->getLastRequest()->getUri(), + ); + } + + public function testFalseHotUrlUsesTheViteHotFile(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.hot_url' => false, + ]); + + $this->createHotFile('http://localhost:5173'); + + $mock = $this->mockSsrClient([ + new GuzzleResponse(200, [], json_encode([ + 'head' => [], + 'body' => '
Hot Response
', + ])), + ]); + + $this->assertNotNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertSame( + 'http://localhost:5173/__inertia_ssr', + (string) $mock->getLastRequest()->getUri(), + ); + } + + public function testItFallsBackToClientRenderingWhenTheHotFileDisappears(): void + { + Event::fake([SsrRenderFailed::class]); + + config(['inertia.ssr.enabled' => true]); + $this->createHotFile(); + $this->mockSsrClient([]); + + $gateway = new HotFileRemovedHttpGateway; + + $this->assertNull($gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + + Event::assertNotDispatched(SsrRenderFailed::class); } public function testItUsesViteHotUrlEvenWhenBundleFileExists(): void @@ -340,6 +472,38 @@ public function testItDispatchesEventWhenSsrFails(): void }); } + public function testItNormalizesMalformedRemoteErrorMetadata(): void + { + Event::fake([SsrRenderFailed::class]); + + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + ]); + + $this->mockSsrClient([ + new GuzzleResponse(500, [], json_encode([ + 'error' => ['invalid'], + 'type' => 123, + 'hint' => false, + 'browserApi' => ['window'], + 'stack' => new stdClass, + 'sourceLocation' => 10, + ])), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + + Event::assertDispatched(SsrRenderFailed::class, function (SsrRenderFailed $event) { + return $event->error === 'Unknown SSR error' + && $event->type === SsrErrorType::Unknown + && $event->hint === null + && $event->browserApi === null + && $event->stack === null + && $event->sourceLocation === null; + }); + } + public function testItHandlesConnectionErrorsGracefully(): void { Event::fake([SsrRenderFailed::class]); @@ -502,7 +666,7 @@ public function testItDoesNotThrowExceptionWhenThrowOnErrorIsDisabled(): void $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); } - public function testCircuitBreakerSkipsSsrAfterFailure(): void + public function testRemoteConnectionErrorDoesNotActivateTransportBackoff(): void { config([ 'inertia.ssr.enabled' => true, @@ -510,26 +674,65 @@ public function testCircuitBreakerSkipsSsrAfterFailure(): void 'inertia.ssr.backoff' => 5.0, ]); - $this->mockSsrClient([ + $mock = $this->mockSsrClient([ new GuzzleResponse(500, [], json_encode([ 'error' => 'Server down', 'type' => 'connection', ])), - // Second response would succeed, but circuit breaker prevents it new GuzzleResponse(200, [], json_encode([ 'head' => ['SSR'], 'body' => '
SSR
', ])), ]); - // First dispatch fails — triggers circuit breaker $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertNotNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertSame(0, $mock->count()); + } + + public function testConnectionFailureActivatesTransportBackoff(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.backoff' => 5.0, + ]); + + $mock = $this->mockSsrClient([ + new ConnectException('Connection refused', new GuzzleRequest('POST', '/render')), + new GuzzleResponse(200, [], json_encode([ + 'head' => [], + 'body' => '
SSR
', + ])), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertSame(1, $mock->count()); + } + + public function testMalformedSuccessActivatesTransportBackoff(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.backoff' => 5.0, + ]); + + $mock = $this->mockSsrClient([ + new GuzzleResponse(200, [], json_encode(['head' => [], 'body' => []])), + new GuzzleResponse(200, [], json_encode([ + 'head' => [], + 'body' => '
SSR
', + ])), + ]); - // Second dispatch should be skipped (circuit breaker active) $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertSame(1, $mock->count()); } - public function testCircuitBreakerResetsAfterFlushState(): void + public function testTransportBackoffResetsAfterFlushState(): void { config([ 'inertia.ssr.enabled' => true, @@ -538,27 +741,65 @@ public function testCircuitBreakerResetsAfterFlushState(): void ]); $this->mockSsrClient([ - new GuzzleResponse(500, [], json_encode(['error' => 'Server down', 'type' => 'connection'])), + new ConnectException('Connection refused', new GuzzleRequest('POST', '/render')), new GuzzleResponse(200, [], 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(); - // Re-inject testing client since flushState clears it $this->mockSsrClient([ new GuzzleResponse(200, [], json_encode(['head' => ['SSR'], 'body' => '
SSR
'])), ]); - // 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 testHealthCheckBypassesTransportBackoff(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.backoff' => 5.0, + ]); + + $this->mockSsrClient([ + new ConnectException('Connection refused', new GuzzleRequest('POST', '/render')), + new GuzzleResponse(200), + ]); + + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertTrue($this->gateway->isHealthy()); + } + + public function testInFlightSuccessClearsTransportBackoff(): void + { + config([ + 'inertia.ssr.enabled' => true, + 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', + 'inertia.ssr.backoff' => 5.0, + ]); + + $backoff = new ReflectionProperty(HttpGateway::class, 'ssrUnavailableUntil'); + $this->mockSsrClient([ + static function () use ($backoff): GuzzleResponse { + $backoff->setValue(null, microtime(true) + 5.0); + + return new GuzzleResponse(200, [], json_encode(['head' => [], 'body' => '
First
'])); + }, + new GuzzleResponse(200, [], json_encode(['head' => [], 'body' => '
Second
'])), + ]); + + $first = $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + $second = $this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT); + + $this->assertSame('
First
', $first?->body); + $this->assertSame('
Second
', $second?->body); + } + public function testItHandlesScalarJsonErrorResponseGracefully(): void { Event::fake([SsrRenderFailed::class]); @@ -568,11 +809,14 @@ public function testItHandlesScalarJsonErrorResponseGracefully(): void 'inertia.ssr.bundle' => __DIR__ . '/Fixtures/ssr-bundle.js', ]); - $this->mockSsrClient([ + $mock = $this->mockSsrClient([ new GuzzleResponse(500, [], '"Internal Server Error"'), + new GuzzleResponse(200, [], json_encode(['head' => [], 'body' => '
SSR
'])), ]); $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); + $this->assertSame(1, $mock->count()); Event::assertDispatched(SsrRenderFailed::class, function (SsrRenderFailed $event) { return $event->error === 'Unknown SSR error'; @@ -669,3 +913,16 @@ public function testSsrClientUsesConfiguredTimeouts(): void $this->assertSame($client, $method->invoke($gateway)); } } + +class HotFileRemovedHttpGateway extends HttpGateway +{ + /** + * Remove the hot file immediately before resolving its URL. + */ + protected function getHotUrl(string $path = '/'): ?string + { + unlink(Vite::hotFile()); + + return parent::getHotUrl($path); + } +} diff --git a/tests/Inertia/InertiaServiceProviderTest.php b/tests/Inertia/InertiaServiceProviderTest.php index 9f8d5e527..1267a6cc5 100644 --- a/tests/Inertia/InertiaServiceProviderTest.php +++ b/tests/Inertia/InertiaServiceProviderTest.php @@ -5,14 +5,19 @@ namespace Hypervel\Tests\Inertia; use Hypervel\Contracts\Http\Kernel as HttpKernelContract; +use Hypervel\Filesystem\Filesystem; use Hypervel\Http\Request; use Hypervel\Inertia\InertiaServiceProvider; use Hypervel\Inertia\Middleware\EnsureGetOnRedirect; +use Hypervel\Inertia\Ssr\Gateway; +use Hypervel\Inertia\Ssr\HttpGateway; use Hypervel\RateLimiter\Limit; use Hypervel\Support\Facades\Blade; use Hypervel\Support\Facades\RateLimiter; use Hypervel\Support\Facades\Route; use Hypervel\Tests\Inertia\Fixtures\ExampleMiddleware; +use Hypervel\View\ViewFinderInterface; +use InvalidArgumentException; use Mockery as m; class InertiaServiceProviderTest extends TestCase @@ -56,6 +61,62 @@ public function testEnsureGetOnRedirectMiddlewareIsRegisteredGlobally(): void $this->assertTrue($kernel->hasMiddleware(EnsureGetOnRedirect::class)); } + public function testGatewayContractResolvesTheConcreteWorkerInstance(): void + { + $this->assertSame( + $this->app->make(HttpGateway::class), + $this->app->make(Gateway::class), + ); + } + + public function testInertiaViewFinderIsSharedAndCanBeRebound(): void + { + $finder = $this->app->make('inertia.view-finder'); + + $this->assertSame($finder, $this->app->make('inertia.view-finder')); + + $replacement = m::mock(ViewFinderInterface::class); + $this->app->bind('inertia.view-finder', fn () => $replacement); + + $this->assertSame($replacement, $this->app->make('inertia.view-finder')); + } + + public function testInertiaViewFinderReusesSuccessfulLookupsWithConfiguredPathsAndExtensions(): void + { + config()->set('inertia.pages.paths', ['/inertia-pages']); + config()->set('inertia.pages.extensions', ['vue']); + + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->once()->with('/inertia-pages/Dashboard.vue')->andReturn(true); + $this->app->instance('files', $files); + + $finder = $this->app->make('inertia.view-finder'); + + $this->assertSame('/inertia-pages/Dashboard.vue', $finder->find('Dashboard')); + $this->assertSame('/inertia-pages/Dashboard.vue', $finder->find('Dashboard')); + } + + public function testInertiaViewFinderDoesNotCacheMisses(): void + { + config()->set('inertia.pages.paths', ['/inertia-pages']); + config()->set('inertia.pages.extensions', ['vue']); + + $files = m::mock(Filesystem::class); + $files->shouldReceive('exists')->twice()->with('/inertia-pages/Missing.vue')->andReturn(false); + $this->app->instance('files', $files); + + $finder = $this->app->make('inertia.view-finder'); + + foreach (range(1, 2) as $_) { + try { + $finder->find('Missing'); + $this->fail('Expected the missing component lookup to fail.'); + } catch (InvalidArgumentException) { + // A failed lookup must query the filesystem again next time. + } + } + } + public function testRedirectMiddlewareRegistersThroughTheKernelContract(): void { $kernel = m::mock(HttpKernelContract::class); From f9ac467f9d55f99ed701d2ebaea3b64b5a8f7eaa Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:17:17 +0000 Subject: [PATCH 5/9] fix(inertia): report SSR command outcomes truthfully Replace process-global PCNTL handlers with the inherited coroutine-scoped signal registry while retaining Node, Bun, and absolute runtime selection. Return failure for unsuccessful child exits instead of reporting every completed process as success. Make the stop command verify server health through the shared transport before shutdown, distinguish refusal from the normal response-less close, and return standard console outcomes. Cover runtime arguments, signal cleanup, child exits, health failures, returned statuses, and closed shutdown connections. --- src/inertia/src/Commands/StartSsr.php | 14 ++---- src/inertia/src/Commands/StopSsr.php | 23 +++++----- tests/Inertia/Commands/StartSsrTest.php | 36 +++++++++++++-- tests/Inertia/Commands/StopSsrTest.php | 61 +++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 24 deletions(-) create mode 100644 tests/Inertia/Commands/StopSsrTest.php diff --git a/src/inertia/src/Commands/StartSsr.php b/src/inertia/src/Commands/StartSsr.php index 51a8f4d0d..3ba33751e 100644 --- a/src/inertia/src/Commands/StartSsr.php +++ b/src/inertia/src/Commands/StartSsr.php @@ -66,15 +66,9 @@ public function handle(): int $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); - } + $this->trap([SIGINT, SIGQUIT, SIGTERM], function () use ($process): void { + $process->stop(); + }); foreach ($process as $type => $data) { if ($process::OUT === $type) { @@ -85,6 +79,6 @@ public function handle(): int } } - return self::SUCCESS; + return $process->isSuccessful() ? self::SUCCESS : self::FAILURE; } } diff --git a/src/inertia/src/Commands/StopSsr.php b/src/inertia/src/Commands/StopSsr.php index d486e36b2..7cbcd6582 100644 --- a/src/inertia/src/Commands/StopSsr.php +++ b/src/inertia/src/Commands/StopSsr.php @@ -4,10 +4,9 @@ namespace Hypervel\Inertia\Commands; +use GuzzleHttp\Exception\TransferException; use Hypervel\Console\Command; -use Hypervel\Http\Client\ConnectionException; use Hypervel\Inertia\Ssr\HttpGateway; -use Hypervel\Support\Facades\Http; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'inertia:stop-ssr')] @@ -16,7 +15,7 @@ class StopSsr extends Command /** * The console command name. */ - protected ?string $name = 'inertia:stop-ssr'; + protected ?string $signature = 'inertia:stop-ssr'; /** * The console command description. @@ -28,19 +27,21 @@ class StopSsr extends Command */ public function handle(HttpGateway $gateway): int { - $url = $gateway->getProductionUrl('/shutdown'); + if (! $gateway->isHealthy()) { + $this->error('Unable to connect to Inertia SSR server.'); + + return self::FAILURE; + } 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.'); + if (! $gateway->shutdown()) { + $this->error('Inertia SSR server refused to stop.'); return self::FAILURE; } + } catch (TransferException) { + // The official shutdown endpoint terminates after a verified health + // response and may close the connection without sending a response. } $this->info('Inertia SSR server stopped.'); diff --git a/tests/Inertia/Commands/StartSsrTest.php b/tests/Inertia/Commands/StartSsrTest.php index 84ad5ed4e..c605ac042 100644 --- a/tests/Inertia/Commands/StartSsrTest.php +++ b/tests/Inertia/Commands/StartSsrTest.php @@ -4,7 +4,11 @@ namespace Hypervel\Tests\Inertia\Commands; +use Hypervel\Console\SignalRegistry; +use Hypervel\Inertia\Commands\StartSsr; use Hypervel\Tests\Inertia\TestCase; +use Mockery as m; +use ReflectionProperty; use Symfony\Component\Process\Process; class StartSsrTest extends TestCase @@ -20,12 +24,15 @@ protected function setUp(): void config()->set('inertia.ssr.bundle', __FILE__); } - protected function fakeProcess(): void + /** + * @param list $command + */ + protected function fakeProcess(array $command = ['true']): void { - $this->app->bind(Process::class, function ($app, $params) { + $this->app->bind(Process::class, function ($app, $params) use ($command) { $this->processCommand = $params['command']; - return new Process(['true']); + return new Process($command); }); } @@ -142,4 +149,27 @@ public function testRuntimeIsNotCheckedByDefault(): void $this->assertSame('nonexistent-runtime-binary', $this->processCommand[0]); } + + public function testReturnsFailureWhenTheChildProcessFails(): void + { + $this->fakeProcess([PHP_BINARY, '-r', 'exit(1);']); + + $this->artisan('inertia:start-ssr')->assertExitCode(1); + } + + public function testRegistersTerminationSignalsThroughTheCommandRegistry(): void + { + $this->fakeProcess(); + + $registry = m::mock(SignalRegistry::class); + $registry->shouldReceive('register') + ->once() + ->with([SIGINT, SIGQUIT, SIGTERM], m::type('callable')); + $registry->shouldReceive('unregister')->once()->with(null); + + $command = $this->app->make(StartSsr::class); + (new ReflectionProperty($command, 'signalRegistry'))->setValue($command, $registry); + + $this->artisan('inertia:start-ssr')->assertExitCode(0); + } } diff --git a/tests/Inertia/Commands/StopSsrTest.php b/tests/Inertia/Commands/StopSsrTest.php new file mode 100644 index 000000000..424b212e7 --- /dev/null +++ b/tests/Inertia/Commands/StopSsrTest.php @@ -0,0 +1,61 @@ +shouldReceive('isHealthy')->once()->andReturn(false); + $gateway->shouldNotReceive('shutdown'); + $this->app->instance(HttpGateway::class, $gateway); + + $this->artisan('inertia:stop-ssr') + ->expectsOutput('Unable to connect to Inertia SSR server.') + ->assertExitCode(1); + } + + public function testSucceedsWhenTheSsrServerStops(): void + { + $gateway = m::mock(HttpGateway::class); + $gateway->shouldReceive('isHealthy')->once()->andReturn(true); + $gateway->shouldReceive('shutdown')->once()->andReturn(true); + $this->app->instance(HttpGateway::class, $gateway); + + $this->artisan('inertia:stop-ssr') + ->expectsOutput('Inertia SSR server stopped.') + ->assertExitCode(0); + } + + public function testFailsWhenTheSsrServerRefusesToStop(): void + { + $gateway = m::mock(HttpGateway::class); + $gateway->shouldReceive('isHealthy')->once()->andReturn(true); + $gateway->shouldReceive('shutdown')->once()->andReturn(false); + $this->app->instance(HttpGateway::class, $gateway); + + $this->artisan('inertia:stop-ssr') + ->expectsOutput('Inertia SSR server refused to stop.') + ->assertExitCode(1); + } + + public function testAcceptsAResponseLessCloseAfterTheHealthCheck(): void + { + $gateway = m::mock(HttpGateway::class); + $gateway->shouldReceive('isHealthy')->once()->andReturn(true); + $gateway->shouldReceive('shutdown')->once()->andThrow(new TransferException('Connection closed')); + $this->app->instance(HttpGateway::class, $gateway); + + $this->artisan('inertia:stop-ssr') + ->expectsOutput('Inertia SSR server stopped.') + ->assertExitCode(0); + } +} From 2d726f546ec46f1976bb40488fe1d5cec5c5c8ee Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:17:26 +0000 Subject: [PATCH 6/9] chore(inertia): complete API and test type documentation Add concise Laravel-style method docblocks to the exception response and facade extension surfaces without changing behavior or visibility. Complete the missing void return types in the bundle detector tests so the touched package test surface follows the repository typing conventions. --- src/inertia/src/ExceptionResponse.php | 23 +++++++++++++++++++++++ src/inertia/src/Inertia.php | 3 +++ tests/Inertia/BundleDetectorTest.php | 4 ++-- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/inertia/src/ExceptionResponse.php b/src/inertia/src/ExceptionResponse.php index c55544e75..b5aca34c5 100644 --- a/src/inertia/src/ExceptionResponse.php +++ b/src/inertia/src/ExceptionResponse.php @@ -25,6 +25,9 @@ class ExceptionResponse implements Responsable /** @var null|class-string */ protected ?string $middlewareClass = null; + /** + * Create a new exception response instance. + */ public function __construct( public readonly Throwable $exception, public readonly Request $request, @@ -35,6 +38,8 @@ public function __construct( } /** + * Render the exception with the given Inertia component. + * * @param array $props */ public function render(string $component, array $props = []): static @@ -46,6 +51,8 @@ public function render(string $component, array $props = []): static } /** + * Use the given Inertia middleware. + * * @param class-string $middlewareClass */ public function usingMiddleware(string $middlewareClass): static @@ -55,6 +62,9 @@ public function usingMiddleware(string $middlewareClass): static return $this; } + /** + * Include the middleware shared data. + */ public function withSharedData(): static { $this->includeSharedData = true; @@ -62,6 +72,9 @@ public function withSharedData(): static return $this; } + /** + * Set the root view. + */ public function rootView(string $rootView): static { $this->rootView = $rootView; @@ -69,6 +82,9 @@ public function rootView(string $rootView): static return $this; } + /** + * Return the response status code. + */ public function statusCode(): int { return $this->response->getStatusCode(); @@ -109,6 +125,9 @@ public function toResponse(Request $request): Response ->setStatusCode($this->response->getStatusCode()); } + /** + * Resolve the Inertia middleware for the request. + */ protected function resolveMiddleware(): ?Middleware { if ($this->middlewareClass) { @@ -125,6 +144,8 @@ protected function resolveMiddleware(): ?Middleware } /** + * Resolve the Inertia middleware from the route. + * * @return null|class-string */ protected function resolveMiddlewareFromRoute(): ?string @@ -151,6 +172,8 @@ protected function resolveMiddlewareFromRoute(): ?string } /** + * Resolve the Inertia middleware from the HTTP kernel. + * * @return null|class-string */ protected function resolveMiddlewareFromKernel(): ?string diff --git a/src/inertia/src/Inertia.php b/src/inertia/src/Inertia.php index 73707747e..d38ea0f97 100644 --- a/src/inertia/src/Inertia.php +++ b/src/inertia/src/Inertia.php @@ -45,6 +45,9 @@ */ class Inertia extends Facade { + /** + * Get the registered name of the component. + */ protected static function getFacadeAccessor(): string { return ResponseFactory::class; diff --git a/tests/Inertia/BundleDetectorTest.php b/tests/Inertia/BundleDetectorTest.php index 78c3a189a..59bbde313 100644 --- a/tests/Inertia/BundleDetectorTest.php +++ b/tests/Inertia/BundleDetectorTest.php @@ -8,7 +8,7 @@ class BundleDetectorTest extends TestCase { - public function testDetectCachesResultForWorkerLifetime() + public function testDetectCachesResultForWorkerLifetime(): void { config()->set('inertia.ssr.bundle', __FILE__); @@ -24,7 +24,7 @@ public function testDetectCachesResultForWorkerLifetime() $this->assertSame(__FILE__, $second); } - public function testFlushStateResetsCache() + public function testFlushStateResetsCache(): void { config()->set('inertia.ssr.bundle', __FILE__); From 79fc41e956f0be2f8108101dbf13c9016f6b7f67 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:17:35 +0000 Subject: [PATCH 7/9] docs(inertia): explain SSR operation and testing Document runtime selection, configurable hot URLs, transport timeouts, bounded worker backoff, client-rendered fallback, failure events, and throw-on-error behavior in the canonical Vite guide using Laravel-style prose. Keep the package README minimal while recording the public raw-client testing seam and standardizing the upstream reference. Complete the Vite table of contents and keep the starter-kit note attached to the start command. --- src/boost/docs/vite.md | 20 ++++++++++++++++++++ src/inertia/README.md | 6 +++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/boost/docs/vite.md b/src/boost/docs/vite.md index 61f737f5f..0d20d60e2 100644 --- a/src/boost/docs/vite.md +++ b/src/boost/docs/vite.md @@ -15,6 +15,10 @@ - [Inertia](#inertia) - [URL Processing](#url-processing) - [Working With Stylesheets](#working-with-stylesheets) +- [Working With Fonts](#working-with-fonts) + - [Font Providers](#font-providers) + - [Local Fonts](#local-fonts) + - [Font Options](#font-options) - [Working With Blade and Routes](#working-with-blade-and-routes) - [Processing Static Assets With Vite](#blade-processing-static-assets) - [Refreshing on Save](#blade-refreshing-on-save) @@ -24,6 +28,7 @@ - [Environment Variables](#environment-variables) - [Disabling Vite in Tests](#disabling-vite-in-tests) - [Server-Side Rendering (SSR)](#ssr) + - [Configuring Inertia SSR](#configuring-inertia-ssr) - [Script and Style Tag Attributes](#script-and-style-attributes) - [Content Security Policy (CSP) Nonce](#content-security-policy-csp-nonce) - [Subresource Integrity (SRI)](#subresource-integrity-sri) @@ -858,6 +863,21 @@ php artisan inertia:start-ssr > [!NOTE] > Hypervel's [starter kits](/docs/{{version}}/starter-kits) already include the proper Hypervel, Inertia SSR, and Vite configuration. These starter kits offer the fastest way to get started with Hypervel, Inertia SSR, and Vite. + +### Configuring Inertia SSR + +By default, the `inertia:start-ssr` command uses Node. You may select another runtime, such as Bun, using the `--runtime` option: + +```shell +php artisan inertia:start-ssr --runtime=bun +``` + +You may also configure the runtime using the `INERTIA_SSR_RUNTIME` environment variable. Runtime values may be executable names or absolute paths. + +The `hot_url` option within your application's `inertia.ssr` configuration may be used to specify the SSR server URL while Vite is running. This option may also be configured using the `INERTIA_SSR_HOT_URL` environment variable. The `connect_timeout` and `timeout` options control how long Hypervel waits for the SSR server, while the `backoff` option determines how long a worker skips SSR after a connection failure or malformed response. + +When an SSR render request fails, Hypervel renders the page on the client and dispatches a `Hypervel\Inertia\Ssr\SsrRenderFailed` event. To throw an exception instead, enable the `throw_on_error` option within your application's `inertia.ssr` configuration. + ## Script and Style Tag Attributes diff --git a/src/inertia/README.md b/src/inertia/README.md index 37ea26f0e..f95b0f4a5 100644 --- a/src/inertia/README.md +++ b/src/inertia/README.md @@ -2,4 +2,8 @@ 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. +## Differences From Laravel + +Hypervel sends SSR requests using a dedicated reusable HTTP client. Therefore, `Http::fake()` and `Http::preventStrayRequests()` do not intercept SSR requests. Tests may replace the SSR client using `Hypervel\Inertia\Ssr\HttpGateway::useTestingClient()`. + +Ported from: https://github.com/inertiajs/inertia-laravel From afd143e4bdc4329096da89f051b66f43a4f34b1c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:17:50 +0000 Subject: [PATCH 8/9] docs: record the Inertia maintenance design Capture the final request-state, prop ownership, protocol, SSR transport, command lifecycle, documentation, performance, testing, and compatibility decisions in the focused implementation plan. Update the core routing index and durable ledger with inertia-01 through inertia-23, completed validation and review evidence, rejected machinery, the revalidated support boundary, and the current upstream DevTools surface reserved as the next work unit. Keep the package checklist open until DevTools lands. --- ...amework-coroutine-state-lifecycle-audit.md | 8 +- ...-coroutine-state-lifecycle-audit-ledger.md | 25 + ...ctness-ssr-lifecycle-and-current-parity.md | 569 ++++++++++++++++++ 3 files changed, 598 insertions(+), 4 deletions(-) create mode 100644 docs/plans/2026-08-07-2018-inertia-correctness-ssr-lifecycle-and-current-parity.md diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index 50ec755ac..4c61ffbff 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,9 +990,9 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** None. `socialite` is complete; detail plan `2026-08-07-1416-socialite-correctness-first-party-extensibility-and-lifecycle.md`. -- **Ledger entries required for the active work:** None. The completed Socialite work is recorded under `Complete Socialite correctness, first-party extensibility, and lifecycle`, with its cross-package findings recorded at their owning package entries. -- **Pending revalidation carried into the active work:** None. Socialite revalidated `support-02` and completed `support-34`, `object-pool-04`, and `reverb-40` at their owning boundaries. +- **Active package or work unit:** `inertia`; correctness and SSR lifecycle maintenance is recorded under `Complete Inertia correctness and SSR lifecycle maintenance`; detail plan `2026-08-07-2018-inertia-correctness-ssr-lifecycle-and-current-parity.md`. Current upstream DevTools is the next Inertia work unit. +- **Ledger entries required for the active work:** `Complete Inertia correctness and SSR lifecycle maintenance`. +- **Pending revalidation carried into the active work:** None. Inertia revalidated `support-02`; current upstream DevTools remains separately scoped before the package checklist can be completed. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1053,7 +1053,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `queue-11` | `queue` | `events`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-11` | | `queue-12` | `bus`, `queue` | `events`, `bus`, `queue`, and `broadcasting` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `queue-12` | | `foundation-01` | `foundation` | `support` and `foundation` (revalidation complete) | `Correct event dispatch, queued-consumer isolation, and queue interoperability`; finding `foundation-01` | -| `support-02` | `support` | `auth` (revalidation complete), `broadcasting` (revalidation complete), `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia`, `jwt`, `log`, `mail`, `notifications` (revalidation complete), `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing` (revalidation complete), `sanctum` (revalidation complete), `scout`, `session` (revalidation complete), `socialite` (revalidation complete), `telescope`, `testbench`; `translation` (revalidation complete); later full remaining consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | +| `support-02` | `support` | `auth` (revalidation complete), `broadcasting` (revalidation complete), `bus` (revalidation complete), `cache` (revalidation complete), `concurrency`, `console` (revalidation complete), `container`, `contracts`, `cookie`, `database` (revalidation complete), `events`, `filesystem` (revalidation complete), `foundation` (revalidation complete), `hashing` (revalidation complete), `horizon` (revalidation complete), `inertia` (revalidation complete), `jwt`, `log`, `mail`, `notifications` (revalidation complete), `permission`, `pipeline`, `queue` (revalidation complete), `redis` (revalidation complete), `reverb` (revalidation complete), `routing` (revalidation complete), `sanctum` (revalidation complete), `scout`, `session` (revalidation complete), `socialite` (revalidation complete), `telescope`, `testbench`; `translation` (revalidation complete); later full remaining consumer audits | `Normalize framework enum identifiers at string boundaries`; finding `support-02`; sibling findings `translation-01` and `reverb-03`; linked detail plan `2026-07-15-0920-framework-enum-identifier-contracts.md` | | `macroable-03` | `macroable` | `cookie`, `log`, and `notifications` (revalidation complete); later full `jwt` audit | `Complete Macroable callable and test-state handling`; finding `macroable-03` | | `auth-01` | `support`, `auth` | `auth` (revalidation complete) | `Correct Support utility boundaries and authentication timing isolation`; finding `auth-01` | | `encryption-03` | `encryption` | `contracts`, `support`, `filesystem`, and `foundation` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `encryption-03` | diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 0b6ec4c96..0626cacef 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -1966,3 +1966,28 @@ Append package entries in checklist order. Keep each entry compact but complete - **Performance and complexity:** Ordinary non-Socialite requests are unchanged. Provider construction adds one non-yielding integer increment, request paths add bounded local array/string checks beside existing network work, and JWKS reuse removes repeated network requests while bounding headerless reuse to five minutes by default and retaining one key set and one refresh timestamp per provider. No request path gains a lock, timer, background job, registry, unbounded map, clone, container lookup, serialization layer, or additional ordinary network round trip. - **Validation and review:** Changed tests passed during implementation; focused Socialite, Support, Object Pool, and Reverb coverage, root and split Composer validation, facade and documentation checks, stale-symbol scans, formatting, both PHPStan configurations, the complete parallel components suite, Testbench package mode, dogfood, and `git diff --check` passed. Review independently reproduced the provider-namespace collision, stale config rebinding, nonce-disabled failure, partial-user memoization, and response-state risks, then signed off after every source and plan correction landed. - **Assessment:** Socialite is coroutine-safe, worker-lifecycle-aware, protocol-correct, current at the supported Laravel surface, and first-party extensible without ecosystem-manager machinery. Every accepted finding is fixed at its lowest owner; no stale response state, compatibility workaround, speculative abstraction, unresolved accepted defect, meaningful performance regression, or deferred TODO remains. + +### Complete Inertia correctness and SSR lifecycle maintenance + +- **Status and inspected surface:** Maintenance implementation, focused validation, the authoritative gate, fresh self-review, and independent code review are complete. The work covered every reported Inertia finding plus related request-state, response, resolver, SSR transport, command, provider, documentation, and test surfaces. Current upstream DevTools remains the next separate Inertia work unit, so the package checklist stays open. The detailed design is recorded in [`2026-08-07-2018-inertia-correctness-ssr-lifecycle-and-current-parity.md`](2026-08-07-2018-inertia-correctness-ssr-lifecycle-and-current-parity.md). + +| Findings | Final decision | +|---|---| +| `inertia-01`, `inertia-18`, `inertia-19`, `inertia-22` | Replicate one provider-boot state baseline into each request, centralize dispatch-once state, reserve the authoritative page payload, and clone mutable scroll props at each resolver path. | +| `inertia-02`, `inertia-04`, `inertia-16` | Restore the version response header, append and deduplicate `Vary: X-Inertia` on the returned response, and replace only exact empty-string content. | +| `inertia-03`, `inertia-15` | Add a configurable hot URL, let falsey environment configuration fall back to the Vite hot file, and treat a checked hot-file publication race as normal client rendering without a failure event. | +| `inertia-05`–`inertia-08`, `inertia-23` | Throw on invalid page JSON, skip duplicate fallback encoding after successful SSR, preserve exact falsey protocol values, memoize resolved null scroll values, normalize numeric prop keys only at the string-path boundary, use strict protocol-list membership, and correct the owner annotations and method types that encoded the same false string-only assumption. | +| `inertia-09`, `inertia-21` | Report actual SSR start/stop outcomes, keep runtime selection neutral, and use coroutine-scoped command signal ownership. | +| `inertia-10`, `inertia-11` | Back off only after connection or malformed-transport failures, validate the exact SSR success shape, and normalize untrusted error metadata. | +| `inertia-12`, `inertia-13`, `inertia-17` | Reuse one page finder, modernize provider resolution, and give the gateway contract and concrete class one worker identity. | +| `inertia-14` | Add indexed Laravel-style SSR configuration guidance, document the raw-client testing seam and shutdown exception, and correct focused docblocks and test types. | +| `inertia-20` | Keep current upstream DevTools as the immediately following implementation and documentation work unit. | + +- **Architecture and worker ownership:** One non-coroutine `InertiaState` stores provider-boot defaults; first request access shallow-clones it into coroutine context. Request state, page data, SSR dispatch/result, and request configuration never flow back to the baseline. The raw cookie-free SSR client, transport backoff, bundle detection, and successful page-finder cache remain bounded worker state. Mutable `ScrollProp` resolution is isolated per logical path without cloning arbitrary user props or walking the complete shared tree. `support-02` is revalidated through all Inertia identifier boundaries. +- **DevTools follow-up:** Implement from current upstream after reviewing implementation PR `inertiajs/inertia-laravel#892`, follow-ups `#894`–`#897`, and documentation PR `inertiajs/docs#79`. PR `#895` landed, was reverted, and later re-landed, so use the current source rather than an intermediate diff. The current surface is `Collector.php`; `Data/{IncomingEntry,PropType,RequestType}.php`; `DevTools.php`; `DevToolsHeader.php`; `DevToolsServiceProvider.php`; `EntriesRepository.php`; `EntryStore.php`; `Http/{Authorize,EntriesController,PreserveFlashData,PreventPreviousUrlTracking}.php`; `IncomingEntryBuilder.php`; `PropClassifier.php`; `RedactsSensitiveData.php`; `RequestAttribute.php`; `RequestRecorder.php`; and `SourceLocator.php`. +- **Important rejected concerns:** Do not replace the reusable SSR client with per-request facade calls; deep-clone arbitrary props or complete context; synchronize breaker discovery; retry failed renders; add a generic response schema, DTO hierarchy, clock, shutdown capability interface, file synchronization, cache invalidation registry, watcher, polling, raw socket, or daemon manager; or treat remote error metadata as authority over worker backoff. +- **Regression coverage:** Tests prove boot/request state inheritance and sibling isolation; exact response headers and falsey boundaries; authoritative page rendering; JSON failure chains and SSR encoding short-circuiting; numeric prop paths and non-coercing reset/once membership; null, provider-produced, shared, and multi-path scroll props; configured, falsey, and file-backed hot URL selection plus publication races without synthetic failure events; every valid and malformed SSR response family; remote metadata normalization; event and throw behavior; connection/malformed backoff and healthy recovery; gateway/finder identity and cache behavior; Node, Bun, absolute runtime, signal ownership, child exit, health, refusal, and response-less shutdown paths; documentation claims; and cleanup/state invariants. +- **Performance and complexity:** Healthy SSR preserves one reusable client and connection pool while removing a second full-page JSON encode. First request access performs one shallow state clone instead of allocating an unconfigured state; each resolved scroll prop adds one necessary shallow clone. Other request additions are bounded header-list, string, and response-shape checks. Finder singleton reuse preserves its existing successful lookup cache. No request path gains a lock, retry, poll, serializer layer, container loop, extra ordinary network round trip, unbounded cache, or retained request state. +- **Laravel-facing result:** Supported Inertia methods, named arguments, props, middleware contracts, helpers, command options, runtimes, and protected extension points remain compatible. Hypervel retains its coroutine-safe state and reusable-client optimizations. Reserving the framework-owned `page` view key and strict-type key normalization correct internal protocol inconsistencies rather than removing documented application APIs. +- **Validation and review:** Every changed test file and the complete Inertia suite passed. The final authoritative `composer fix` gate passed formatting, both PHPStan configurations, the complete parallel suite, Testbench package mode, and dogfood after the review corrections for falsey hot configuration, strict protocol membership, precise view-data precedence, complete method typing, and documentation. The boot-shared scroll regression added during self-review also passed its focused file. The prop annotations were corrected for contract truthfulness rather than a PHPStan failure. `git diff --check`, stale-symbol scans, caller/callee, API, state-lifetime, hot-path, failure-path, and overengineering review, and independent code-review sign-off are complete. +- **Assessment:** The maintenance surface is coroutine-safe, failure-truthful, cache-preserving, and bounded for long-lived workers. Every accepted maintenance finding is fixed at its lowest owner without a workaround, speculative abstraction, meaningful performance regression, unintended Laravel API break, stale superseded path, or deferred defect. DevTools is the only remaining planned Inertia parity work. diff --git a/docs/plans/2026-08-07-2018-inertia-correctness-ssr-lifecycle-and-current-parity.md b/docs/plans/2026-08-07-2018-inertia-correctness-ssr-lifecycle-and-current-parity.md new file mode 100644 index 000000000..41c967191 --- /dev/null +++ b/docs/plans/2026-08-07-2018-inertia-correctness-ssr-lifecycle-and-current-parity.md @@ -0,0 +1,569 @@ +# Inertia Correctness, SSR Lifecycle, and Current Parity + +## Scope and outcome + +Complete the accepted Inertia maintenance work before porting the separately scoped DevTools feature. Preserve the package's Laravel-style API and Hypervel's performance adaptations: coroutine-local request state, worker-cached immutable metadata, one reusable cookie-free SSR client with bounded I/O and connection reuse, worker-wide transport backoff, and configurable SSR runtimes including Node, Bun, and absolute executable paths. + +The final package must inherit provider-boot configuration into each request without sharing mutable request state; preserve Inertia cache variance and version headers; publish truthful initial-page JSON; classify SSR failures without poisoning unrelated pages; validate the established SSR response shape; report command outcomes accurately; and reuse the existing page-finder cache. DevTools remains the immediately following feature-parity work unit, and the core package checklist remains unchecked until it lands. + +References checked for this design: + +- Hypervel Inertia source, configuration, facade, package metadata, README, Boost Vite guide, every package test, Context/Container/Console owners, and repository consumers at `de04fad613a8`; +- current `inertiajs/inertia-laravel` 3.x source/tests/configuration at `c014246529dd`, including current version-mismatch, hot URL, and DevTools surfaces; +- originating upstream changes for the version header and custom hot URL, plus the installed `@inertiajs/core` 3.5.0 health, render, and response-less shutdown endpoints; +- Symfony HttpFoundation `Vary`, streamed/binary content, Symfony Process, Hypervel `SignalRegistry`, Guzzle cURL and stream handlers, and Hypervel HTTP exception wrapping; +- the completed `support-02` enum/string-boundary decision and the core audit's checked-native-boundary rule. + +Focused evidence reproduced lost non-coroutine boot configuration, immediate-success `StartSsr` tests leaking process-global PCNTL handlers, nonzero child exits reported as success, stream-handler shutdown ambiguity, null scroll callbacks executing twice, malformed SSR payloads escaping as `TypeError`, and view data overriding the authoritative Inertia page. An isolated real-process probe verified that Hypervel's coroutine-scoped `trap()` stops a Symfony child from a sibling waiter coroutine and then re-raises SIGTERM as exit 143. + +## What this audit is not + +The following wording is retained verbatim from the core audit plan. Its principle numbering is also retained; principles 1–6 remain in the core operating plan. In principle 9, “later in this plan” refers to that plan's **Established remediation vocabulary** section. + +This audit is not permission to add defensive machinery for every imaginable failure. Do not add an abstraction, state machine, retry loop, configurable timeout, registry, mutex, context slot, cache, or compatibility API merely because it sounds robust. + +Complexity must pay for itself with at least one of: + +- a demonstrated failure; +- a complete source trace proving a realistic vulnerable schedule; +- a clear general capability with real consumers and owner approval; +- deletion of greater or riskier complexity elsewhere. + +Typical Laravel lifecycle semantics define the supported contract. A package that intentionally relies on model events, middleware, listeners, transactions, or another documented mechanism is not defective merely because userland can explicitly bypass that mechanism. Do not build a parallel enforcement path for `withoutEvents()`, raw database writes, disabled middleware, direct transport access, or comparable deliberate bypasses unless the public contract explicitly promises behavior through that bypass. + +Underengineering is equally a failure. Fix every verified defect completely at its lowest owning boundary, never with a partial fix or a local patch over a broken shared contract, and always surface meaningful evidence-backed improvements rather than dropping them to avoid effort. Restraint applies to speculative machinery and cosmetic change, not to complete fixes or worthwhile opportunities. + +Do not treat an upstream difference as a bug without tracing it. Do not treat upstream parity as proof of correctness. A real Hypervel defect remains a defect when Laravel, Hyperf, Symfony, or an SDK has the same hole. + +The audit categories are discovery lenses, not boundaries around what may be corrected. Any genuine issue discovered while auditing, implementing, testing, or reviewing must be investigated, assigned to its lowest owning boundary, and taken through the applicable consensus, implementation, validation, review, and approval workflow—even when it is outside the current package, initial taxonomy, or changed diff. Do not dismiss a verified issue as unrelated or defer it merely to preserve package order. This rule applies only after the evidence threshold is met; it does not turn speculative concerns, deliberate bypasses, unsupported use, or contract violations into work. + +### 8. Remove superseded design completely + +When a fix changes the owning model, delete obsolete helpers, callbacks, properties, config keys, comments, tests, and documentation. Do not leave a compatibility path or comment describing behavior that no longer exists. Preserve intentional upstream comments unless the new design makes them incorrect. + +### 9. Treat remediation patterns as candidates + +The established patterns later in this plan are a vocabulary, not a lookup table. Choose among per-call parameters, immutable values, scoped bindings, cloning, CoroutineContext, factories, explicit ownership, static reset, or resource teardown only after proving the real lifetime and owner. + +### 10. Reject speculative complexity + +Record low-confidence concerns under rejected or unresolved analysis. Do not implement them. Surface every evidence-backed, meaningful non-defect improvement to the owner with its benefit, cost, and alternatives, then stop for explicit approval. This requirement exists to keep worthwhile opportunities visible, not to discourage finding them. + +## Findings and final decisions + +| ID | Category / severity | Final decision | +|---|---|---| +| `inertia-01` | Request-lifecycle defect / Major | Make `InertiaState` a replicable boot baseline and the sole request-state accessor; shallow-clone it once on first request access. | +| `inertia-02` | Protocol parity defect / Minor | Add the current asset version header to version-mismatch location responses. | +| `inertia-03` | Current parity improvement | Add `INERTIA_SSR_HOT_URL`, prefer it over the Vite hot file, and normalize both base and requested paths. | +| `inertia-04` | HTTP cache defect / Major | Append and case-insensitively deduplicate `Vary: X-Inertia` on the response actually returned. | +| `inertia-05` | Serialization defect / Major | Throw on invalid initial-page JSON instead of emitting an empty payload. | +| `inertia-06` | Performance defect / Improvement | Do not encode the full page again when SSR already supplied the rendered body. | +| `inertia-07` | Boundary correctness / Minor | Replace truthiness at the seven evidenced identifier, header, event, and helper contracts while preserving unrelated value semantics. | +| `inertia-08` | Memoization defect / Minor | Cache a null `ScrollProp` result with one explicit resolution flag. | +| `inertia-09` | Command outcome defect / Major | Make SSR start/stop commands report actual child/server outcomes through one gateway transport and handler-independent health preflight. | +| `inertia-10` | Worker-availability defect / Major | Arm worker backoff only for connection or malformed-transport failures, never page-local render failures. | +| `inertia-11` | Transport-contract defect / Major | Validate exactly `{head: array, body: string}`, normalize remote error metadata, and remove the dead exception catch. | +| `inertia-12` | Approved performance improvement | Singleton-bind the opt-in page finder so its existing successful-path cache survives repeated resolutions. | +| `inertia-13` | Port modernization / Minor | Replace the provider's two container array accesses with canonical `make()` calls. | +| `inertia-14` | Documentation/conformance defect / Minor | Document public SSR operation and testing differences; correct focused prose, docblocks, and package test return types. | +| `inertia-15` | Checked-native-boundary defect / Minor | Treat a Vite hot file removed between metadata check and read as normal client-rendered fallback. | +| `inertia-16` | Response replacement defect / Major | Treat only exact empty-string content as empty; preserve `"0"`, streamed, and binary responses. | +| `inertia-17` | Container identity defect / Major | Make the concrete `HttpGateway` auto-singleton authoritative and bind `Gateway` to that instance. | +| `inertia-18` | State-owner cleanup / Minor | Put dispatch-once behavior in one `InertiaState` instance method and delete component copies and the static wrapper. | +| `inertia-19` | Render-consistency defect / Major | Reserve view key `page` for the framework-built page so directives and components cannot render different payloads. | +| `inertia-20` | Current feature parity / Separate work unit | Record current upstream DevTools as the next implementation/documentation plan; do not mark Inertia complete before it lands. | +| `inertia-21` | Process-lifecycle defect / Major | Replace raw process-global PCNTL handlers with the inherited coroutine-scoped `trap()` API. | +| `inertia-22` | Scroll ownership defect / Major | Clone each `ScrollProp` at the resolver's per-path boundary so framework resolution cannot leak results across requests or couple metadata between paths. | +| `inertia-23` | Strict-port correctness / Minor | Cast top-level array keys at the string path boundary and correct the stale string-only prop annotations so numeric props retain Laravel's weak-coercion behavior under strict types. | + +The seven original `inertia-07` sites do not all have equal real-world harm. `getShared('')` is a concrete wrong public result, the helper contradicts its declared null-conditional return, protocol key `"0"` is reachable, and the remaining exact comparisons make declared nullable/string boundaries truthful. `inertia-16` is separate because `empty()` currently destroys real streamed/binary responses. + +## Implementation + +### 1. Publish one boot baseline and one request state + +Make `InertiaState` implement `ReplicableContext`. `current()` works identically inside and outside coroutines: outside provider boot creates or retrieves the non-coroutine baseline; inside a request, first access shallow-clones that baseline into the coroutine. Fail loudly if the package-owned context key contains a foreign value; use a local PHPDoc narrowing rather than a runtime type guard. + +```php +public static function current(): self +{ + if (CoroutineContext::has(self::CONTEXT_KEY)) { + /** @var self $state */ + $state = CoroutineContext::get(self::CONTEXT_KEY); + + return $state; + } + + // Providers configure Inertia before the server starts request coroutines, + // so each request begins with an independent copy of that boot baseline. + /** @var self|null $baseline */ + $baseline = CoroutineContext::getFromNonCoroutine(self::CONTEXT_KEY); + + $state = $baseline?->replicate() ?? new self; + CoroutineContext::set(self::CONTEXT_KEY, $state); + + return $state; +} + +public function replicate(): static +{ + return clone $this; +} +``` + +`current(): self` and `new self` are deliberate: this fixed context key is the package's one internal state type, not a late-static factory. `replicate(): static` follows the `ReplicableContext` contract and preserves the runtime type of an object already stored in context; it does not create a subclass-construction API whose instances would collide under the same key. + +Shallow cloning is deliberate: package arrays use PHP copy-on-write, closures are immutable, and arbitrary caller-owned prop objects retain their normal identity. The framework-owned mutable `ScrollProp` is isolated later at its resolver boundary. Do not copy all non-coroutine context, deep-clone user data, add static factory state, or share the baseline object itself. + +The worker retains one baseline `InertiaState` plus the values providers share at boot. Provider boot writes it once; request mutations apply only to clones, so request traffic cannot grow it. SSR dispatch happens only while rendering inside a request coroutine, so the baseline's page and dispatch/result slots remain empty. It needs no package `flushState()` or subscriber entry: the existing global `CoroutineContext::flush()` clears non-coroutine storage between tests. Pin that dependency in the state tests. A request-local `flushShared()` clears only its clone and must not remove boot-shared props from sibling or later requests. + +Update the class docblock to describe both roles accurately: one provider-boot baseline in non-coroutine storage and an independent request-local copy in coroutine context. Do not leave it claiming the object exists only per request. + +Route every state read in `ResponseFactory`, `Response`, `HttpGateway`, `App`, `Head`, and the directive path through `current()`. Replace the three dispatch-once copies with one instance owner: + +```php +public function dispatchSsr(): ?SsrResponse +{ + if (! $this->ssrDispatched) { + $this->ssrDispatched = true; + $this->ssrResponse = app(Gateway::class)->dispatch($this->page); + } + + return $this->ssrResponse; +} +``` + +Components call this method on the current state. Compiled directives explicitly assign their view-scope `$page` to the current state before dispatch because directives can render without `Response::toResponse()` ever seeding state. Do not add `setPage()` or retain a one-line static wrapper. + +In `Response::toResponse()`, make the resolved page authoritative in both state and view data: + +```php +$state = InertiaState::current(); +$state->page = $page; + +return ResponseFactory::view( + $this->rootView, + ['page' => $page] + $this->viewData, +); +``` + +This intentionally prevents `withViewData(['page' => ...])` from splitting directive JSON/SSR from component rendering without renumbering unrelated keys at this merge boundary. It changes no Laravel-documented capability: `page` is framework-owned protocol data. + +### 2. Make response headers and empty-content handling exact + +Port the current version header in `onVersionChange()`: + +```php +$response = Inertia::location($request->fullUrl()); +$response->headers->set(Header::VERSION, Inertia::getVersion()); + +return $response; +``` + +Append `X-Inertia` to `Vary` on both the non-Inertia early return and the final Inertia response, after any replacement. Use Symfony's parsed list and a case-insensitive comparison; do not parse the header manually: + +```php +protected function addInertiaVaryHeader(Response $response): void +{ + foreach ($response->getVary() as $header) { + if (strcasecmp($header, Header::INERTIA) === 0) { + return; + } + } + + $response->setVary(Header::INERTIA, false); +} +``` + +An Inertia response is empty only when materialized content is exactly `''`: + +```php +if ($response->isOk() && $response->getContent() === '') { + $response = $this->onEmptyResponse($request, $response); +} +``` + +`false` from `StreamedResponse` or `BinaryFileResponse` means content is not materialized; `"0"` is legitimate content. Neither is a redirect signal. + +### 3. Make initial-page serialization truthful and avoid duplicate work + +Use native JSON encoding with the throwing flag in the component and compiled directive fallback: + +```php +json_encode($page, JSON_THROW_ON_ERROR) +``` + +Retain native encoding so normal HTML bytes and flags do not change. `App` keeps its non-nullable public `$pageJson` initialized, but only encodes when no SSR response exists: + +```php +$this->response = $state->dispatchSsr(); +$this->pageJson = $this->response === null + ? json_encode($state->page, JSON_THROW_ON_ERROR) + : ''; +``` + +Do not add a JSON wrapper, sanitizer, lazy value object, or a second rendering component. + +### 4. Correct exact nullable and string boundaries + +Apply only these evidenced changes: + +| Owner | Exact contract | +|---|---| +| `ResponseFactory::getShared()` | `null` means all shared props; `''` and `"0"` are keys and use the supplied default when absent. | +| `inertia()` | only `null` returns the factory; every string delegates to `render()`. | +| `PropsResolver::parseHeader()` | remove only exact `''`; preserve `"0"`; return `null` when no entries remain. | +| `Middleware::resolveValidationErrors()` | treat a non-null, non-empty error-bag header—including `"0"`—as named. | +| `MergesProps::append()` / `prepend()` | accept non-null `matchOn`, including `"0"`; empty string retains the existing absence behavior. | +| `Directive::compile()` | default the root ID only for exact `''`; preserve `"0"`. | +| `SsrRenderFailed::toArray()` | filter only `null`, preserving diagnostic `"0"`. | + +Representative forms: + +```php +if ($key !== null) { + return Arr::get($sharedProps, $key, $default); +} + +$values = array_filter( + explode(',', $this->request->header($key, '')), + fn (string $value): bool => $value !== '', +); + +return $values === [] ? null : $values; +``` + +Do not trim official comma-joined client headers or sweep unrelated falsey application values. + +At the one top-level prop-path producer, normalize the path without changing the resolved array key: + +```php +$path = $prefix === '' ? (string) $key : "{$prefix}.{$key}"; +``` + +This matches the existing casts in `resolveSharedProps()` and Laravel's weak coercion. Correct the four affected `PropsResolver` return annotations to `array`, the `InertiaState::$sharedProps` and `Response::$props` annotations to `array`, and type `Response::with()` as `array|ProvidesInertiaProperties|int|string`; type `withViewData()` as `array|string`. These are truthful contract corrections rather than PHPStan fixes. Use strict membership for rescued, once, reset, and redirect-method lists so numeric-looking strings cannot collide. Do not widen downstream string path contracts or other string-keyed response shapes, and do not add key validation. + +### 5. Make SSR transport classification and payload validation authoritative + +Preserve the worker-cached raw Guzzle client, cookies-off policy, configured connect/total timeouts, and successful connection reuse. As soon as a render request returns an HTTP response, clear prior backoff because transport reachability is proven. A malformed response then re-arms backoff; a structured page render error does not. + +Validate only the established top-level success shape: + +```php +protected function isValidSsrResponse(mixed $data): bool +{ + if (! is_array($data) + || ! isset($data['head'], $data['body']) + || ! is_array($data['head']) + || ! is_string($data['body']) + ) { + return false; + } + + foreach ($data['head'] as $head) { + if (! is_string($head)) { + return false; + } + } + + return true; +} +``` + +Empty `head` is valid. Invalid JSON, scalar JSON, missing keys, non-string head entries, or a non-string body enter the existing failure-event/fallback path with transport backoff. Do not require `array_is_list()`, recursively inspect application page data, add JSON Schema, or create DTO/error hierarchies. + +Keep the classification explicit at the three call sites: + +```php +$response = $this->ssrClient()->request(/* ... */); +self::$ssrUnavailableUntil = null; + +if ($response->getStatusCode() >= 400) { + $decoded = json_decode((string) $response->getBody(), true); + $structured = is_array($decoded); + + if (! $structured) { + $this->armTransportBackoff(); + } + + $this->handleSsrFailure($page, $structured ? $decoded : null); + + return null; +} + +$data = json_decode((string) $response->getBody(), true); + +if (! $this->isValidSsrResponse($data)) { + $this->armTransportBackoff(); + $this->handleSsrFailure($page, ['error' => 'Invalid SSR response.']); + + return null; +} +``` + +Connection exceptions arm backoff before calling `handleSsrFailure()` with type `connection`; this ordering survives `throw_on_error`: + +```php +} catch (TransferException $e) { + $this->armTransportBackoff(); + $this->handleSsrFailure($page, [ + 'error' => $e->getMessage(), + 'type' => 'connection', + ]); + + return null; +} +``` + +Keep the protected two-parameter signature unchanged so subclasses remain compatible. Only the transport call sites may arm worker-wide backoff; a remote `type: connection` value must not control worker state. Normalize untrusted optional error fields to `string|null`: + +```php +$event = new SsrRenderFailed( + page: $page, + error: $this->stringOrNull($error['error'] ?? null) ?? 'Unknown SSR error', + type: SsrErrorType::fromString($this->stringOrNull($error['type'] ?? null)), + hint: $this->stringOrNull($error['hint'] ?? null), + browserApi: $this->stringOrNull($error['browserApi'] ?? null), + stack: $this->stringOrNull($error['stack'] ?? null), + sourceLocation: $this->stringOrNull($error['sourceLocation'] ?? null), +); +``` + +Use two private helpers rather than repeating the three call-site writes or four metadata checks: + +```php +/** + * Activate SSR transport backoff. + */ +private function armTransportBackoff(): void +{ + self::$ssrUnavailableUntil = microtime(true) + + (float) config('inertia.ssr.backoff', 5.0); +} + +/** + * Return the value when it is a string. + */ +private function stringOrNull(mixed $value): ?string +{ + return is_string($value) ? $value : null; +} +``` + +Structured browser API, component-resolution, and render failures still dispatch `SsrRenderFailed`, fall back to client rendering, and throw under `throw_on_error`; they do not suppress unrelated pages. Connection and malformed-transport failures arm worker backoff. Delete the dead `catch (SsrException) { throw $e; }`; the remaining catch is already narrowed to `TransferException`. + +Do not add locks, single-flight coordination, retries, a clock service, or another breaker state. Concurrent in-flight discovery is bounded and a racing response is direct reachability evidence. + +### 6. Make hot URL publication checked and current + +Add nullable `inertia.ssr.hot_url` / `INERTIA_SSR_HOT_URL`. A configured URL bypasses the file read. Otherwise immediately check the suppressed native result and return `null` for the established client-rendering fallback: + +```php +protected function getHotUrl(string $path = '/'): ?string +{ + $baseUrl = (string) config('inertia.ssr.hot_url'); + + if ($baseUrl === '') { + $baseUrl = @file_get_contents(Vite::hotFile()); + + if ($baseUrl === false) { + return null; + } + } + + return rtrim(trim($baseUrl), '/') . Str::start($path, '/'); +} +``` + +`dispatch()` returns `null` when the hot URL disappears after `Vite::isRunningHot()`. This is a development publication race, not an SSR server error; do not emit a synthetic failure event or add file synchronization. + +Show that nullable boundary at the dispatch site before making the request: + +```php +$url = $isHot + ? $this->getHotUrl('/__inertia_ssr') + : $this->getProductionUrl('/render'); + +if ($url === null) { + return null; +} +``` + +### 7. Give the gateway and page finder one worker identity + +Use the concrete auto-singleton as the one gateway instance and bind the contract through it: + +```php +$this->app->singleton( + Gateway::class, + fn ($app) => $app->make(HttpGateway::class), +); + +$this->app->singleton('inertia.view-finder', function ($app) { + $config = $app->make('config'); + + return new FileViewFinder( + $app->make('files'), + $config->array('inertia.pages.paths'), + $config->array('inertia.pages.extensions'), + ); +}); +``` + +Direct `HttpGateway::class` and `Gateway::class` resolution must return the same worker object. The view finder reuses its existing bounded successful-path cache only when page-existence enforcement resolves it; misses remain uncached, roots/extensions are boot configuration, and application/test rebinding still wins. Do not add another cache, invalidation API, watcher, or concrete alias. + +Use `$this->app->make('router')` for middleware registration. Do not add container aliases or concrete bindings for static analysis. + +### 8. Make SSR commands truthful and runtime-neutral + +Keep `--runtime`, `INERTIA_SSR_RUNTIME`, Bun, Node, and absolute paths unchanged. Replace raw PCNTL ownership with the inherited coroutine-scoped signal registry: + +```php +$this->trap([SIGINT, SIGQUIT, SIGTERM], function () use ($process): void { + $process->stop(); +}); + +foreach ($process as $type => $data) { + // Existing output handling. +} + +return $process->isSuccessful() ? self::SUCCESS : self::FAILURE; +``` + +Signals are conventionally re-raised by `SignalRegistry` and terminate as `128 + signal`; only normally completed children reach the return. Do not add an extension branch, save/restore native handlers, or special signal-success flag. + +Put transport-only shutdown on concrete `HttpGateway`: + +```php +public function shutdown(): bool +{ + $response = $this->ssrClient()->request( + 'GET', + $this->getProductionUrl('/shutdown'), + ); + + return $response->getStatusCode() >= 200 + && $response->getStatusCode() < 300; +} +``` + +`StopSsr` owns orchestration and messages: + +```php +if (! $gateway->isHealthy()) { + $this->error('Unable to connect to Inertia SSR server.'); + + return self::FAILURE; +} + +try { + if (! $gateway->shutdown()) { + $this->error('Inertia SSR server refused to stop.'); + + return self::FAILURE; + } +} catch (TransferException) { + // The official endpoint terminates after a verified health response + // and closes the connection without sending a response. +} +``` + +The preflight is handler-independent, uses the same client and configured timeouts, and runs only in a CLI command. `isHealthy()` intentionally bypasses render backoff so a degraded server can still be stopped. Do not add cURL errno inspection, raw sockets, polling, retries, daemonization, or a shutdown capability interface. + +Use `$signature` consistently on `StopSsr`. Keep `SsrException::$event` with its other member declarations. + +### 9. Resolve scroll props per path and memoize null results + +`ScrollProp` is the only built-in prop wrapper that mutates during resolution: it memoizes the resolved value and records request-specific merge intent. Clone it at both points where `resolveProps()` begins processing a prop path, including a prop type returned by a callable or property provider: + +```php +// Shared scroll props may outlive a request, and resolution mutates them. +// Resolve each prop path through its own copy. +$prop = $value instanceof ScrollProp ? clone $value : $value; +``` + +Use the same assignment after the one-level prop-type unwrap. The clone must precede filtering and `resolveValue()` so deferred metadata is request-local and `collectMetadata()` observes the resolved copy. This boundary also covers nested and `ProvidesInertiaProperties`-produced scroll props without walking the shared-prop tree. Do not clone inside `resolveValue()`, deep-clone shared data, add a marker interface, or add baseline bookkeeping. + +Using one `ScrollProp` object for two prop paths currently couples their merge paths. Resolving one clone per path corrects that independent defect; each logical prop performs its own callback once and contributes only its own metadata. + +Add one boolean beside the existing value: + +```php +protected bool $hasResolved = false; + +public function __invoke(): mixed +{ + if ($this->hasResolved) { + return $this->resolved; + } + + $this->resolved = $this->resolveCallable($this->value); + $this->hasResolved = true; + + return $this->resolved; +} +``` + +Set the flag only after successful resolution. Setting it first would silently cache the untyped property's default `null` when the callback throws, replacing the current retry behavior and potentially surfacing a misleading pagination-metadata error later. Declare the flag immediately after `$resolved`; do not add a sentinel object, reflection, clone reset, or generic memoizer. + +### 10. Documentation, conformance, and durable records + +Add one concise Laravel-docs-style subsection after `inertia:start-ssr` in `src/boost/docs/vite.md`, list it in the table of contents, and keep the existing starter-kit note attached to the start command. Teach: + +- runtime selection with `--runtime=bun` and `INERTIA_SSR_RUNTIME`; +- `hot_url`, connect/total timeouts, and the worker-scoped connection/malformed-response backoff; +- client-rendered fallback, `throw_on_error`, and `SsrRenderFailed`; +- no internal state-machine or Guzzle implementation narrative. + +Keep the package README minimal. Add only the public testing difference under `Differences From Laravel`: SSR uses a dedicated raw client, so `Http::fake()` and `Http::preventStrayRequests()` do not intercept it; tests use `HttpGateway::useTestingClient()`. Normalize the upstream reference to the standard `Ported from:` line, but do not add a documentation link: the SSR subsection belongs to the Vite guide, not a dedicated Inertia page. Do not duplicate that guide. + +Correct `optimisations` to `optimizations`, “These options configures” to “These options configure”, and backoff prose so only connection/malformed transport failures suppress SSR. Add Laravel-style title docblocks to `ExceptionResponse::__construct()`, `render()`, `usingMiddleware()`, `withSharedData()`, `rootView()`, `statusCode()`, `resolveMiddleware()`, `resolveMiddlewareFromRoute()`, and `resolveMiddlewareFromKernel()`; `Inertia::getFacadeAccessor()`; the `App` and `Head` constructors; and document the transport exception from `HttpGateway::shutdown()`, while preserving useful type tags. Add `: void` to the 14 package-local methods in `ComponentTest`, three in `CoroutineIsolationTest`, and two in `BundleDetectorTest`. + +Update the core ledger with `inertia-01` through `inertia-23`, implementation evidence, rejected concerns, performance/API assessment, and the separately scoped DevTools surface. The DevTools record must name implementation PRs `inertiajs/inertia-laravel#892` and follow-ups `#894`–`#897`, documentation PR `inertiajs/docs#79`, and the 19 current source files: `Collector.php`; `Data/{IncomingEntry,PropType,RequestType}.php`; `DevTools.php`; `DevToolsHeader.php`; `DevToolsServiceProvider.php`; `EntriesRepository.php`; `EntryStore.php`; `Http/{Authorize,EntriesController,PreserveFlashData,PreventPreviousUrlTracking}.php`; `IncomingEntryBuilder.php`; `PropClassifier.php`; `RedactsSensitiveData.php`; `RequestAttribute.php`; `RequestRecorder.php`; and `SourceLocator.php`. Note that `#895` landed, was reverted, then re-landed; use the current branch rather than an intermediate historical diff. Keep the core `inertia` checklist unchecked. Do not add a TODO placeholder or partial DevTools code to this plan. + +## Rejected concerns and required invariants + +- Do not replace the reusable SSR client with per-request `Http` facade calls; connection/handler reuse is intentional and performance-critical. +- Do not claim `Http::fake()` intercepts raw SSR requests; document the testing client seam. +- Do not clone arbitrary prop objects or copy the whole non-coroutine context. +- Do not synchronize circuit-breaker discovery or retry failed renders. +- Do not normalize arbitrary falsey application values beyond the listed owners. +- Do not port upstream's independent Guzzle metadata widening; `hypervel/http` owns that dependency. +- Do not add a real Node/Bun service to the general suite; deterministic transport/process seams cover this adapter. Runtime selection remains tested directly. +- Do not change Console's zero-argument `setSignalsToDispatchEvent()`: current Laravel deliberately uses it to prevent Symfony PCNTL signal ownership, while Hypervel commands use the coroutine-scoped registry. +- Do not mark the package complete while current upstream DevTools is absent. + +No Laravel-facing method, named argument, facade, prop type, middleware contract, helper, or command option is removed. `InertiaState` is a Hypervel-internal lifecycle owner; replacing its static dispatch helper with the single instance owner is not a Laravel API change. Reserving root-view key `page` corrects conflicting internal protocol data rather than removing a documented extension point. + +## Testing plan + +Run each touched test file immediately, then the complete package suite. Required regression coverage: + +1. **State:** boot shared props/root view/version/URL/component controls plus `ssrDisabled` and `ssrExcludedPaths` inherit into requests; sibling mutations remain isolated; request-local `flushShared()` does not clear sibling or later boot props; explicit context copies replicate; fresh requests see baseline but not completed sibling state; request-scoped state written during an HTTP request is asserted inside that request coroutine because its parent sees only the boot baseline and the harness's session/auth synchronization; component and coroutine tests read/write through `InertiaState::current()` while retaining raw `CoroutineContext::forget()` only as their test-only request-boundary reset. +2. **Middleware:** version header; preserved/deduplicated mixed-case `Vary`; ordinary and every replacement response; exact `''`, `"0"`, streamed, and binary content; named error bag `"0"`. +3. **Response/rendering:** authoritative page cannot be overridden through view data; directive/component head/body equivalence; successful SSR skips both fallback encoders even for an otherwise unencodable page; client fallback preserves exact output; one unencodable directive value and one different component value each throw `ViewException` with `JsonException` as the previous exception. Keep the SSR short-circuit in one paired component/directive test and note that its fake gateway does not encode the page. +4. **Identifiers:** null/empty/`"0"` pairs for shared keys, helper components, partial/reset/once headers, merge match keys, root IDs, and failure diagnostics; numeric top-level prop paths work under strict types while retaining their resolved key, and numeric-looking reset/once values do not collide through loose comparison. +5. **Scroll:** repeated direct null resolution invokes the callback once; a boot-shared scroll prop produces distinct sibling-request results without accumulated merge metadata; a provider-produced scroll prop follows the same boundary; and one object used at two paths resolves once per path without duplicated or crossed metadata. +6. **Gateway:** configured/default/falsey hot URL, slash normalization, removed unreadable hot file fallback without a synthetic failure event, one concrete/contract identity, valid empty head, every malformed success/error shape, remote metadata normalization, event dispatch, `throw_on_error`, render-error follow-up success, connection/malformed backoff, in-flight success clearing backoff, and health bypassing backoff. +7. **Commands:** Node/Bun/absolute runtime preservation; zero/nonzero process exit; inherited signal registration without raw PCNTL mutation; healthy/unhealthy preflight; returned 2xx/500; response-less close after health; refusal before health; configured timeout/client seam. +8. **Finder/provider:** singleton identity, successful lookup reuse, configurable paths/extensions, misses not cached, and application rebinding. +9. **Docs/types/records:** README and Vite claims match final source; no direct package-source state lookup remains outside `InertiaState::current()`; no stale raw signal, facade stop transport, or duplicated dispatch block remains. + +After focused tests: + +```shell +./vendor/bin/phpunit --no-progress tests/Inertia +composer fix +``` + +If `composer fix` fails, correct with targeted checks and then run the failed entry plus every remaining script entry as required by `AGENTS.md`. Finish with a full diff review through all callers/callees, current upstream comparison, API/named-argument review, state/lifetime review, static reset review, hot-path allocation/network review, and peer code-review sign-off. + +## Completion criteria + +- Every accepted finding except separately scoped DevTools is implemented with load-bearing coverage and no superseded path. +- DevTools is durably recorded as the immediate next work unit and the package checklist remains unchecked. +- Boot configuration, request state, gateway identity, page data, and command signal ownership each have one authoritative owner. +- Healthy SSR retains its reusable client. Necessary hot-path additions are one first-access shallow state clone in place of the current new-state allocation, one shallow clone per resolved scroll-prop path, Symfony's bounded `Vary` list read, and direct SSR shape checks; successful SSR also deletes the current second full-page encoding and temporary string. +- Documentation is Laravel-style, concise, and truthful; README contains only the public testing difference. +- Focused tests and `composer fix` are green; self-review finds no stale code, workaround, unbounded state, Laravel API break, or speculative machinery; peer review signs off. From 7cb14c42dd0a930dd55b6eaa40068fbd2be09d59 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:41:30 +0000 Subject: [PATCH 9/9] test(inertia): clarify malformed response backoff coverage Document why the second malformed-response dispatch is load-bearing.\n\nThe assertion proves transport backoff is armed before throw_on_error raises the SSR exception, so the queued follow-up response must remain untouched. --- tests/Inertia/HttpGatewayTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Inertia/HttpGatewayTest.php b/tests/Inertia/HttpGatewayTest.php index 52253a095..6119be8a0 100644 --- a/tests/Inertia/HttpGatewayTest.php +++ b/tests/Inertia/HttpGatewayTest.php @@ -201,6 +201,7 @@ public function testMalformedSuccessDispatchesFailureAndHonorsThrowOnError(): vo $this->assertSame('Invalid SSR response.', $exception->event?->error); } + // Backoff must be armed before throw_on_error raises the exception. $this->assertNull($this->gateway->dispatch(self::EXAMPLE_PAGE_OBJECT)); $this->assertSame(1, $mock->count()); Event::assertDispatched(