From fd852c1c473c066e2867792b9956657daf49bf63 Mon Sep 17 00:00:00 2001 From: benkhalife Date: Fri, 25 Sep 2026 03:36:13 -0700 Subject: [PATCH 1/3] fix: collect CSS transitively through imported chunks in Vite::assets() Rollup can fold a CSS-only entry into a shared chunk that a sibling entry only "imports" rather than owning a direct "css" key of its own (e.g. two Vite entries importing the exact same CSS module). renderProductionTags() only read the entry's own manifest "css" key, so the imported entry silently lost its stylesheet tag in production builds even though the CSS file was built and present. Walk the manifest's "imports" chain recursively (with cycle/dedup guards) to collect CSS owned by any chunk the entry depends on. --- src/Vite.php | 55 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/Vite.php b/src/Vite.php index 6adac56..ce0d9de 100644 --- a/src/Vite.php +++ b/src/Vite.php @@ -166,15 +166,16 @@ protected function renderProductionTags(string $entryPoint): string $output = ''; $assetUrlPrefix = $this->getAssetUrlPrefix(); - // 1. CSS files (if styles are extracted) - if (isset($entryData['css']) && is_array($entryData['css'])) { - foreach ($entryData['css'] as $cssFile) { - $output .= sprintf( - '' . PHP_EOL, - $assetUrlPrefix, - $cssFile - ); - } + // 1. CSS files, including CSS pulled in transitively through + // imported chunks (Rollup can fold a CSS-only entry into a shared + // chunk that a sibling entry merely "imports" rather than owning a + // direct "css" key of its own). + foreach ($this->collectCss($manifest, $entryPoint) as $cssFile) { + $output .= sprintf( + '' . PHP_EOL, + $assetUrlPrefix, + $cssFile + ); } // 2. Main JS file @@ -189,6 +190,42 @@ protected function renderProductionTags(string $entryPoint): string return $output; } + /** + * Recursively collect the CSS files owned by a manifest chunk and by + * every chunk it imports, deduplicated and in discovery order. + * + * @param array $manifest The decoded manifest.json + * @param string $chunkKey The manifest key to start from + * @param array $visited Keys already visited, to guard against cycles + * @return string[] Deduplicated list of CSS file paths + */ + protected function collectCss(array $manifest, string $chunkKey, array &$visited = []): array + { + if (isset($visited[$chunkKey]) || !isset($manifest[$chunkKey])) { + return []; + } + $visited[$chunkKey] = true; + + $chunk = $manifest[$chunkKey]; + $css = []; + + if (isset($chunk['css']) && is_array($chunk['css'])) { + foreach ($chunk['css'] as $cssFile) { + $css[$cssFile] = true; + } + } + + if (isset($chunk['imports']) && is_array($chunk['imports'])) { + foreach ($chunk['imports'] as $importedKey) { + foreach ($this->collectCss($manifest, $importedKey, $visited) as $cssFile) { + $css[$cssFile] = true; + } + } + } + + return array_keys($css); + } + /** * Check debug mode */ From c65f64ebe09476ea39e671d6999dd95a8e883235 Mon Sep 17 00:00:00 2001 From: benkhalife Date: Fri, 25 Sep 2026 03:36:19 -0700 Subject: [PATCH 2/3] test: cover Vite::assets() transitive CSS collection 9 tests: collectCss() direct/transitive/deduped/cyclic-safe collection in isolation via reflection, plus end-to-end assets() checks against a manifest fixture reproducing the shared-chunk scenario. --- tests/ViteTest.php | 239 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 tests/ViteTest.php diff --git a/tests/ViteTest.php b/tests/ViteTest.php new file mode 100644 index 0000000..9daefce --- /dev/null +++ b/tests/ViteTest.php @@ -0,0 +1,239 @@ +newInstanceWithoutConstructor(); + } + + private function collectCss(Vite $vite, array $manifest, string $entryPoint): array + { + $method = new ReflectionMethod(Vite::class, 'collectCss'); + $method->setAccessible(true); + + $visited = []; + return $method->invokeArgs($vite, [$manifest, $entryPoint, &$visited]); + } + + // ========================================================================= + // 1. collectCss() + // ========================================================================= + + public function testCollectsDirectCss(): void + { + $manifest = [ + 'app.js' => ['file' => 'app.js', 'css' => ['app.css']], + ]; + + $this->assertSame( + ['app.css'], + $this->collectCss($this->bareInstance(), $manifest, 'app.js') + ); + } + + /** + * Reproduces the real-world bug: an entry with no direct "css" key, + * whose only styles come from a sibling chunk it imports. + */ + public function testCollectsCssTransitivelyThroughImports(): void + { + $manifest = [ + 'admin.js' => ['file' => 'admin.js', 'imports' => ['app.js']], + 'app.js' => ['file' => 'app.js', 'css' => ['app.css']], + ]; + + $this->assertSame( + ['app.css'], + $this->collectCss($this->bareInstance(), $manifest, 'admin.js') + ); + } + + public function testDedupesCssSharedAcrossMultipleImportBranches(): void + { + $manifest = [ + 'admin.js' => ['file' => 'admin.js', 'imports' => ['vendor-a.js', 'vendor-b.js']], + 'vendor-a.js' => ['css' => ['shared.css']], + 'vendor-b.js' => ['css' => ['shared.css']], + ]; + + $this->assertSame( + ['shared.css'], + $this->collectCss($this->bareInstance(), $manifest, 'admin.js') + ); + } + + public function testCombinesOwnCssWithImportedCss(): void + { + $manifest = [ + 'admin.js' => ['file' => 'admin.js', 'css' => ['admin.css'], 'imports' => ['app.js']], + 'app.js' => ['css' => ['app.css']], + ]; + + $this->assertSame( + ['admin.css', 'app.css'], + $this->collectCss($this->bareInstance(), $manifest, 'admin.js') + ); + } + + public function testCyclicImportsDoNotCauseInfiniteRecursion(): void + { + $manifest = [ + 'a.js' => ['css' => ['a.css'], 'imports' => ['b.js']], + 'b.js' => ['css' => ['b.css'], 'imports' => ['a.js']], + ]; + + $this->assertSame( + ['a.css', 'b.css'], + $this->collectCss($this->bareInstance(), $manifest, 'a.js') + ); + } + + public function testUnknownManifestKeyReturnsEmptyArray(): void + { + $manifest = ['app.js' => ['css' => ['app.css']]]; + + $this->assertSame( + [], + $this->collectCss($this->bareInstance(), $manifest, 'missing.js') + ); + } + + public function testEntryWithNeitherCssNorImportsReturnsEmptyArray(): void + { + $manifest = ['app.js' => ['file' => 'app.js']]; + + $this->assertSame( + [], + $this->collectCss($this->bareInstance(), $manifest, 'app.js') + ); + } + + // ========================================================================= + // 2. assets() / renderProductionTags() end-to-end + // ========================================================================= + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testAssetsIncludesTransitiveCssForImportOnlyEntry(): void + { + $vite = $this->bootProductionVite([ + 'resources/js/admin.js' => [ + 'file' => 'assets/admin-hash.js', + 'name' => 'admin', + 'src' => 'resources/js/admin.js', + 'isEntry' => true, + 'imports' => ['resources/js/app.js'], + ], + 'resources/js/app.js' => [ + 'file' => 'assets/app-hash.js', + 'name' => 'app', + 'src' => 'resources/js/app.js', + 'isEntry' => true, + 'css' => ['assets/app-hash.css'], + ], + ]); + + $html = $vite->assets('resources/js/admin.js'); + + $this->assertStringContainsString( + '', + $html + ); + $this->assertStringContainsString( + '', + $html + ); + } + + /** + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function testAssetsStillWorksForEntryWithOwnDirectCss(): void + { + $vite = $this->bootProductionVite([ + 'resources/js/app.js' => [ + 'file' => 'assets/app-hash.js', + 'isEntry' => true, + 'css' => ['assets/app-hash.css'], + ], + ]); + + $html = $vite->assets('resources/js/app.js'); + + $this->assertStringContainsString( + '', + $html + ); + $this->assertStringContainsString( + '', + $html + ); + } + + /** + * Boots a fresh Vite singleton against a temp project root containing + * the given manifest, forced into production mode (isDev = false) so + * the test is deterministic regardless of whether a real Vite dev + * server happens to be listening on the default port. + */ + private function bootProductionVite(array $manifest): Vite + { + $root = sys_get_temp_dir() . '/webrium_vite_test_' . uniqid(); + mkdir($root . '/public/build/.vite', 0755, true); + file_put_contents( + $root . '/public/build/.vite/manifest.json', + json_encode($manifest) + ); + + App::setRootPath($root); + $_SERVER['DOCUMENT_ROOT'] = $root; + Url::reset(); + + $vite = Vite::getInstance(); + $vite->setBasePath($root); + + $isDev = new ReflectionProperty(Vite::class, 'isDev'); + $isDev->setAccessible(true); + $isDev->setValue($vite, false); + + return $vite; + } +} From a88de5beec9367638d200dd6be5a31b79c5a06c2 Mon Sep 17 00:00:00 2001 From: benkhalife Date: Fri, 25 Sep 2026 03:36:25 -0700 Subject: [PATCH 3/3] test(config): register ViteTest.php in the default suite --- phpunit.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/phpunit.xml b/phpunit.xml index fefddce..40f4d33 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -35,6 +35,7 @@ ./tests/AppCorsMiddlewareTest.php ./tests/CronExpressionTest.php ./tests/ScheduleTest.php + ./tests/ViteTest.php