From b696edd0cb8566d2d5a04211906462e9b20edf3a Mon Sep 17 00:00:00 2001 From: atymic Date: Tue, 4 Aug 2026 09:31:14 +1000 Subject: [PATCH] perf(vite): index manifest chunks by output file Resolving a CSS file back to its manifest entry scanned the whole manifest once per CSS reference, inside the recursive walk over dynamic imports. Build the file -> key index once per render and look chunks up directly. Keeps the first match on a duplicate 'file' value, matching the previous where()/first() behaviour. --- src/Illuminate/Foundation/Vite.php | 50 +++++++++---- tests/Foundation/FoundationViteTest.php | 95 +++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 14 deletions(-) diff --git a/src/Illuminate/Foundation/Vite.php b/src/Illuminate/Foundation/Vite.php index 900864368eca..dbe08ce3470d 100644 --- a/src/Illuminate/Foundation/Vite.php +++ b/src/Illuminate/Foundation/Vite.php @@ -396,6 +396,7 @@ public function __invoke($entrypoints, $buildDirectory = null) } $manifest = $this->manifest($buildDirectory); + $chunkKeysByFile = $this->chunkKeysByFile($manifest); $tags = new Collection; $preloads = new Collection; @@ -419,19 +420,20 @@ public function __invoke($entrypoints, $buildDirectory = null) ]); foreach ($manifest[$import]['css'] ?? [] as $css) { - $partialManifest = (new Collection($manifest))->where('file', $css); + $cssKey = $chunkKeysByFile[$css] ?? null; + $cssChunk = $cssKey === null ? null : $manifest[$cssKey]; $preloads->push([ - $partialManifest->keys()->first(), + $cssKey, $this->assetPath("{$buildDirectory}/{$css}"), - $partialManifest->first(), + $cssChunk, $manifest, ]); $tags->push($this->makeTagForChunk( - $partialManifest->keys()->first(), + $cssKey, $this->assetPath("{$buildDirectory}/{$css}"), - $partialManifest->first(), + $cssChunk, $manifest )); } @@ -445,19 +447,20 @@ public function __invoke($entrypoints, $buildDirectory = null) )); foreach ($chunk['css'] ?? [] as $css) { - $partialManifest = (new Collection($manifest))->where('file', $css); + $cssKey = $chunkKeysByFile[$css] ?? null; + $cssChunk = $cssKey === null ? null : $manifest[$cssKey]; $preloads->push([ - $partialManifest->keys()->first(), + $cssKey, $this->assetPath("{$buildDirectory}/{$css}"), - $partialManifest->first(), + $cssChunk, $manifest, ]); $tags->push($this->makeTagForChunk( - $partialManifest->keys()->first(), + $cssKey, $this->assetPath("{$buildDirectory}/{$css}"), - $partialManifest->first(), + $cssChunk, $manifest )); } @@ -481,7 +484,7 @@ public function __invoke($entrypoints, $buildDirectory = null) ->flatMap(fn ($entrypoint) => (new Collection($manifest[$entrypoint]['dynamicImports'] ?? [])) ->map(fn ($import) => $manifest[$import]) ->filter(fn ($chunk) => str_ends_with($chunk['file'], '.js') || str_ends_with($chunk['file'], '.css')) - ->flatMap($f = function ($chunk) use (&$f, $manifest, &$discoveredImports) { + ->flatMap($f = function ($chunk) use (&$f, $manifest, $chunkKeysByFile, &$discoveredImports) { return (new Collection([...$chunk['imports'] ?? [], ...$chunk['dynamicImports'] ?? []])) ->reject(function ($import) use (&$discoveredImports) { if (isset($discoveredImports[$import])) { @@ -495,9 +498,9 @@ public function __invoke($entrypoints, $buildDirectory = null) $f($manifest[$import]) ), new Collection([$chunk])) ->merge((new Collection($chunk['css'] ?? []))->map( - fn ($css) => (new Collection($manifest))->first(fn ($chunk) => $chunk['file'] === $css) ?? [ - 'file' => $css, - ], + fn ($css) => isset($chunkKeysByFile[$css]) + ? $manifest[$chunkKeysByFile[$css]] + : ['file' => $css], )); }) ->map(function ($chunk) use ($buildDirectory, $manifest) { @@ -966,6 +969,25 @@ protected function manifest($buildDirectory) return static::$manifests[$path]; } + /** + * Index the given manifest's chunk keys by their output file. + * + * @param array $manifest + * @return array + */ + protected function chunkKeysByFile($manifest) + { + $index = []; + + foreach ($manifest as $key => $chunk) { + if (isset($chunk['file']) && ! isset($index[$chunk['file']])) { + $index[$chunk['file']] = $key; + } + } + + return $index; + } + /** * Get the path to the manifest file for the given build directory. * diff --git a/tests/Foundation/FoundationViteTest.php b/tests/Foundation/FoundationViteTest.php index 8389737c6074..d5bf4b589110 100644 --- a/tests/Foundation/FoundationViteTest.php +++ b/tests/Foundation/FoundationViteTest.php @@ -115,6 +115,101 @@ public function testViteWithNestedCssImport() $this->cleanViteManifest($buildDir); } + public function testViteResolvesCssChunksReachedThroughNestedImports() + { + $buildDir = Str::random(); + $this->makeViteManifest([ + 'resources/js/app.js' => [ + 'src' => 'resources/js/app.js', + 'file' => 'assets/app.versioned.js', + 'imports' => [ + '_layout.js', + ], + ], + '_layout.js' => [ + 'file' => 'assets/layout.versioned.js', + 'css' => [ + 'assets/layout.versioned.css', + ], + 'imports' => [ + '_header.js', + ], + ], + '_header.js' => [ + 'file' => 'assets/header.versioned.js', + 'css' => [ + 'assets/header.versioned.css', + ], + ], + // The CSS reached through the nested imports above has its own + // manifest entries, which must be resolved back from the file name. + 'resources/css/layout.css' => [ + 'src' => 'resources/css/layout.css', + 'file' => 'assets/layout.versioned.css', + ], + 'resources/css/header.css' => [ + 'src' => 'resources/css/header.css', + 'file' => 'assets/header.versioned.css', + ], + ], $buildDir); + + $vite = app(Vite::class); + $result = $vite(['resources/js/app.js'], $buildDir); + + $this->assertSame( + '' + .'' + .'' + .'' + .'' + .'' + .'' + .'', + $result->toHtml() + ); + + $this->assertSame([ + 'https://example.com/'.$buildDir.'/assets/layout.versioned.css', + 'https://example.com/'.$buildDir.'/assets/header.versioned.css', + 'https://example.com/'.$buildDir.'/assets/app.versioned.js', + 'https://example.com/'.$buildDir.'/assets/layout.versioned.js', + 'https://example.com/'.$buildDir.'/assets/header.versioned.js', + ], array_keys($vite->preloadedAssets())); + + $this->cleanViteManifest($buildDir); + } + + public function testViteUsesTheFirstManifestEntryWhenTwoChunksShareAnOutputFile() + { + $buildDir = Str::random(); + $this->makeViteManifest([ + 'resources/js/app.js' => [ + 'src' => 'resources/js/app.js', + 'file' => 'assets/app.versioned.js', + 'css' => [ + 'assets/shared.versioned.css', + ], + ], + 'resources/css/first.css' => [ + 'src' => 'resources/css/first.css', + 'file' => 'assets/shared.versioned.css', + 'integrity' => 'first-integrity', + ], + 'resources/css/second.css' => [ + 'src' => 'resources/css/second.css', + 'file' => 'assets/shared.versioned.css', + 'integrity' => 'second-integrity', + ], + ], $buildDir); + + $result = app(Vite::class)(['resources/js/app.js'], $buildDir); + + $this->assertStringContainsString('integrity="first-integrity"', $result->toHtml()); + $this->assertStringNotContainsString('second-integrity', $result->toHtml()); + + $this->cleanViteManifest($buildDir); + } + public function testViteHotModuleReplacementWithJsOnly() { $this->makeViteHotFile();