From 4b4269b5ae6f950e86ba57fc6760675649cae5d1 Mon Sep 17 00:00:00 2001 From: Adhik Joshi Date: Fri, 16 Oct 2026 02:04:29 +0530 Subject: [PATCH] Give each coroutine its own Vite so preload links stay per request Vite is a container singleton, so every coroutine in a worker shared one preloaded-assets list. Concurrent requests appended to it, and FlushVite emptied it whenever another request started, so a response's Link header carried another page's preload links or none. RequestScope now hands each request a clone of the boot-time Vite with an empty list. The list is reset directly because Vite::flush() only exists from Laravel 12.10. --- src/Swoole/Coroutine/RequestScope.php | 26 ++++ tests/Unit/RequestScopeViteIsolationTest.php | 155 +++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 tests/Unit/RequestScopeViteIsolationTest.php diff --git a/src/Swoole/Coroutine/RequestScope.php b/src/Swoole/Coroutine/RequestScope.php index 709cb67..56cb386 100644 --- a/src/Swoole/Coroutine/RequestScope.php +++ b/src/Swoole/Coroutine/RequestScope.php @@ -313,6 +313,7 @@ protected function resolveSpecializedBinding(string $key, Application $sandbox) 'translator', \Illuminate\Contracts\Translation\Translator::class, \Illuminate\Translation\Translator::class => $this->createTranslator($sandbox), \Illuminate\Session\Middleware\StartSession::class => $this->createStartSessionMiddleware($sandbox), 'url' => $this->createUrlGenerator($sandbox), + \Illuminate\Foundation\Vite::class => $this->createVite(), 'view', \Illuminate\Contracts\View\Factory::class => $this->createViewFactory($sandbox), \Illuminate\Routing\Contracts\CallableDispatcher::class => new \Illuminate\Routing\CallableDispatcher($sandbox), \Illuminate\Routing\Contracts\ControllerDispatcher::class => new \Illuminate\Routing\ControllerDispatcher($sandbox), @@ -1393,6 +1394,31 @@ protected function createInertiaResponseFactory(): \Inertia\ResponseFactory return new \Inertia\ResponseFactory; } + /** + * Create an isolated Vite instance for the current coroutine. + * + * Vite records every asset @vite renders in its preloaded-assets list, and + * AddLinkHeadersForPreloadedAssets turns that list into the Link header. On + * the shared singleton, concurrent requests appended to one list and the + * FlushVite listener emptied it whenever another request started, so a page + * shipped another page's preload links or none. The clone keeps boot-time + * configuration (build directory, nonce, resolvers) with an empty list. + * + * Vite::flush() only exists from Laravel 12.10, so the list is reset directly. + * + * @return \Illuminate\Foundation\Vite + */ + protected function createVite(): \Illuminate\Foundation\Vite + { + $vite = clone $this->app->make(\Illuminate\Foundation\Vite::class); + + (function () { + $this->preloadedAssets = []; + })->call($vite); + + return $vite; + } + /** * Create an isolated view factory for the current coroutine. * diff --git a/tests/Unit/RequestScopeViteIsolationTest.php b/tests/Unit/RequestScopeViteIsolationTest.php new file mode 100644 index 0000000..d30894e --- /dev/null +++ b/tests/Unit/RequestScopeViteIsolationTest.php @@ -0,0 +1,155 @@ +applicationWithVite(); + $baseVite = $base->make(Vite::class); + $this->recordPreload($baseVite, '/build/assets/left-over.js'); + + $scope = new RequestScope($base); + $sandbox = new CoroutineApplication($base); + + $scopedVite = $scope->resolve(Vite::class, $sandbox); + + $this->assertInstanceOf(Vite::class, $scopedVite); + $this->assertNotSame($baseVite, $scopedVite); + $this->assertSame('custom-build', $this->readProperty($scopedVite, 'buildDirectory')); + $this->assertSame([], $scopedVite->preloadedAssets()); + $this->assertSame($scopedVite, $scope->resolve(Vite::class, $sandbox)); + } + + public function test_a_request_that_starts_mid_render_does_not_touch_another_requests_preloads(): void + { + if (! class_exists(Coroutine::class) || ! class_exists(Channel::class)) { + $this->markTestSkipped('Swoole coroutine support is required.'); + } + + $base = $this->applicationWithVite(); + $sandbox = new CoroutineApplication($base); + $results = []; + + // Wire the globals the way OnWorkerStart does: Blade's @vite resolves through + // app(), AddLinkHeadersForPreloadedAssets through the uncached Vite facade. + $previousContainer = Container::getInstance(); + $previousFacadeApp = Facade::getFacadeApplication(); + $cached = new ReflectionProperty(Facade::class, 'cached'); + $previousCached = $cached->getValue(); + + Container::setInstance($sandbox); + Facade::setFacadeApplication($sandbox); + Facade::clearResolvedInstances(); + $cached->setValue(null, false); + + try { + Coroutine\run(function () use ($base, $sandbox, &$results) { + $aRendered = new Channel(1); + $bRendered = new Channel(1); + $done = new Channel(2); + + // Request A renders its page, then yields before its Link header is built. + Coroutine::create(function () use ($base, $sandbox, $aRendered, $bRendered, $done) { + $scope = new RequestScope($base); + Context::set('octane.request_scope', $scope); + + (new FlushVite)->handle((object) ['sandbox' => $sandbox]); + $this->recordPreload(app(Vite::class), '/build/assets/page-a.js'); + + $aRendered->push(true); + $bRendered->pop(); + + $done->push(['a' => array_keys(ViteFacade::preloadedAssets())]); + + $scope->clear(); + Context::clear(); + }); + + // Request B starts (RequestReceived runs FlushVite) and renders meanwhile. + Coroutine::create(function () use ($base, $sandbox, $aRendered, $bRendered, $done) { + $aRendered->pop(); + + $scope = new RequestScope($base); + Context::set('octane.request_scope', $scope); + + (new FlushVite)->handle((object) ['sandbox' => $sandbox]); + $this->recordPreload(app(Vite::class), '/build/assets/page-b.js'); + + $bRendered->push(true); + + $done->push(['b' => array_keys(ViteFacade::preloadedAssets())]); + + $scope->clear(); + Context::clear(); + }); + + for ($i = 0; $i < 2; $i++) { + $results += $done->pop(); + } + }); + } finally { + $cached->setValue(null, $previousCached); + Facade::clearResolvedInstances(); + Facade::setFacadeApplication($previousFacadeApp); + Container::setInstance($previousContainer); + } + + $this->assertSame(['/build/assets/page-a.js'], $results['a']); + $this->assertSame(['/build/assets/page-b.js'], $results['b']); + $this->assertSame([], $base->make(Vite::class)->preloadedAssets()); + } + + private function applicationWithVite(): Application + { + $app = new Application(__DIR__); + $app->singleton(Vite::class); + $app->make(Vite::class)->useBuildDirectory('custom-build'); + + return $app; + } + + /** + * Record a preload the way rendering @vite does. + */ + private function recordPreload(Vite $vite, string $url): void + { + $reflection = new ReflectionClass($vite); + $property = $reflection->getProperty('preloadedAssets'); + + $assets = $property->getValue($vite); + $assets[$url] = ['rel="modulepreload"', 'as="script"']; + + $property->setValue($vite, $assets); + } + + private function readProperty(object $object, string $property) + { + $reflection = new ReflectionClass($object); + + return $reflection->getProperty($property)->getValue($object); + } +}