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