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 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 */ 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; + } +}