diff --git a/src/Illuminate/Foundation/Vite.php b/src/Illuminate/Foundation/Vite.php
index 46deeb8f0041..380fa74ed192 100644
--- a/src/Illuminate/Foundation/Vite.php
+++ b/src/Illuminate/Foundation/Vite.php
@@ -5,6 +5,7 @@
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Collection;
use Illuminate\Support\HtmlString;
+use Illuminate\Support\Js;
use Illuminate\Support\Str;
use Illuminate\Support\Traits\Macroable;
@@ -96,6 +97,20 @@ class Vite implements Htmlable
*/
protected static $manifests = [];
+ /**
+ * The prefetching strategy to use.
+ *
+ * @var null|'waterfall'|'aggressive'
+ */
+ protected $prefetchStrategy = null;
+
+ /**
+ * The number of assets to load concurrently when using the "waterfall" strategy.
+ *
+ * @var int
+ */
+ protected $prefetchConcurrently = 3;
+
/**
* Get the preloaded assets.
*
@@ -266,6 +281,47 @@ public function usePreloadTagAttributes($attributes)
return $this;
}
+ /**
+ * Use the "waterfall" prefetching strategy.
+ *
+ * @param int|null $concurrency
+ * @return $this
+ */
+ public function useWaterfallPrefetching(?int $concurrency = null)
+ {
+ return $this->usePrefetchStrategy('waterfall', [
+ 'concurrency' => $concurrency ?? $this->prefetchConcurrently
+ ]);
+ }
+
+ /**
+ * Use the "aggressive" prefetching strategy.
+ *
+ * @return $this
+ */
+ public function useAggressivePrefetching()
+ {
+ return $this->usePrefetchStrategy('aggressive');
+ }
+
+ /**
+ * Set the prefetching strategy.
+ *
+ * @param 'waterfall'|'aggressive'|null $strategy
+ * @param array $config
+ * @return $this
+ */
+ public function usePrefetchStrategy($strategy, $config = [])
+ {
+ $this->prefetchStrategy = $strategy;
+
+ if ($strategy === 'waterfall') {
+ $this->prefetchConcurrently = $config['concurrency'] ?? $this->prefetchConcurrently;
+ }
+
+ return $this;
+ }
+
/**
* Generate Vite tags for an entrypoint.
*
@@ -363,7 +419,122 @@ public function __invoke($entrypoints, $buildDirectory = null)
->sortByDesc(fn ($args) => $this->isCssPath($args[1]))
->map(fn ($args) => $this->makePreloadTagForChunk(...$args));
- return new HtmlString($preloads->join('').$stylesheets->join('').$scripts->join(''));
+ $base = $preloads->join('').$stylesheets->join('').$scripts->join('');
+
+ if ($this->prefetchStrategy === null || $this->isRunningHot()) {
+ return new HtmlString($base);
+ }
+
+ $discoveredImports = [];
+
+ return collect($entrypoints)
+ ->flatMap(fn ($entrypoint) => collect($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) {
+ return collect([...$chunk['imports'] ?? [], ...$chunk['dynamicImports'] ?? []])
+ ->reject(function ($import) use (&$discoveredImports) {
+ if (isset($discoveredImports[$import])) {
+ return true;
+ }
+
+ return ! $discoveredImports[$import] = true;
+ })
+ ->reduce(
+ fn ($chunks, $import) => $chunks->merge(
+ $f($manifest[$import])
+ ), collect([$chunk]))
+ ->merge(collect($chunk['css'] ?? [])->map(
+ fn ($css) => collect($manifest)->first(fn ($chunk) => $chunk['file'] === $css) ?? [
+ 'file' => $css,
+ ],
+ ));
+ })
+ ->map(function ($chunk) use ($buildDirectory, $manifest) {
+ return collect([
+ ...$this->resolvePreloadTagAttributes(
+ $chunk['src'] ?? null,
+ $url = $this->assetPath("{$buildDirectory}/{$chunk['file']}"),
+ $chunk,
+ $manifest,
+ ),
+ 'rel' => 'prefetch',
+ 'fetchpriority' => 'low',
+ 'href' => $url,
+ ])->reject(
+ fn ($value) => in_array($value, [null, false], true)
+ )->mapWithKeys(fn ($value, $key) => [
+ $key = (is_int($key) ? $value : $key) => $value === true ? $key : $value,
+ ])->all();
+ })
+ ->reject(fn ($attributes) => isset($this->preloadedAssets[$attributes['href']])))
+ ->unique('href')
+ ->values()
+ ->pipe(fn ($assets) => with(Js::from($assets), fn ($assets) => match ($this->prefetchStrategy) {
+ 'waterfall' => new HtmlString($base.<<
+ window.addEventListener('load', () => window.setTimeout(() => {
+ const makeLink = (asset) => {
+ const link = document.createElement('link')
+
+ Object.keys(asset).forEach((attribute) => {
+ link.setAttribute(attribute, asset[attribute])
+ })
+
+ return link
+ }
+
+ const loadNext = (assets, count) => window.setTimeout(() => {
+ if (count > assets.length) {
+ count = assets.length
+
+ if (count === 0) {
+ return
+ }
+ }
+
+ const fragment = new DocumentFragment
+
+ while (count > 0) {
+ const link = makeLink(assets.shift())
+ fragment.append(link)
+ count--
+
+ if (assets.length) {
+ link.onload = () => loadNext(assets, 1)
+ link.error = () => loadNext(assets, 1)
+ }
+ }
+
+ document.head.append(fragment)
+ })
+
+ loadNext({$assets}, {$this->prefetchConcurrently})
+ }))
+
+ HTML),
+ 'aggressive' => new HtmlString($base.<<
+ window.addEventListener('load', () => window.setTimeout(() => {
+ const makeLink = (asset) => {
+ const link = document.createElement('link')
+
+ Object.keys(asset).forEach((attribute) => {
+ link.setAttribute(attribute, asset[attribute])
+ })
+
+ return link
+ }
+
+ const fragment = new DocumentFragment
+ {$assets}.forEach((asset) => fragment.append(makeLink(asset)))
+ document.head.append(fragment)
+ }))
+
+ HTML),
+ }));
}
/**
diff --git a/tests/Foundation/FoundationViteTest.php b/tests/Foundation/FoundationViteTest.php
index 950fc2c21495..8f19c8e7d379 100644
--- a/tests/Foundation/FoundationViteTest.php
+++ b/tests/Foundation/FoundationViteTest.php
@@ -6,6 +6,7 @@
use Illuminate\Foundation\ViteException;
use Illuminate\Foundation\ViteManifestNotFoundException;
use Illuminate\Support\Facades\Vite as ViteFacade;
+use Illuminate\Support\Js;
use Illuminate\Support\Str;
use Orchestra\Testbench\TestCase;
@@ -1297,6 +1298,360 @@ protected function makeViteManifest($contents = null, $path = 'build')
file_put_contents(public_path("{$path}/manifest.json"), $manifest);
}
+ public function testItCanPrefetchEntrypoint()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__.'/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->usePublicPath(__DIR__);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->usePrefetchStrategy('waterfall')->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertSame(<<
+
+ HTML, $html);
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testItHandlesSpecifyingPageWithAppJs()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__.'/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->usePublicPath(__DIR__);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js', 'resources/js/Pages/Auth/Login.vue'])->useBuildDirectory($buildDir)->usePrefetchStrategy('waterfall')->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItCanSpecifyWaterfallChunks()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__.'/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->usePublicPath(__DIR__);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->useWaterfallPrefetching(concurrency: 10)->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItCanPrefetchAggressively()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__.'/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->usePublicPath(__DIR__);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->useAggressivePrefetching()->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ]);
+
+ $this->assertSame(<<
+
+ HTML, $html);
+
+ $this->cleanViteManifest($buildDir);
+ }
+
+ public function testAddsAttributesToPrefetchTags()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__.'/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->usePublicPath(__DIR__);
+
+ $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js'])->useBuildDirectory($buildDir)->usePrefetchStrategy('waterfall'))->useCspNonce('abc123')->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'nonce' => 'abc123', 'fetchpriority' => 'low'],
+ ]);
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItNormalisesAttributes()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__.'/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->usePublicPath(__DIR__);
+
+ $html = (string) tap(ViteFacade::withEntryPoints(['resources/js/app.js']))->useBuildDirectory($buildDir)->usePrefetchStrategy('waterfall')->usePreloadTagAttributes([
+ 'key' => 'value',
+ 'key-only',
+ 'true-value' => true,
+ 'false-value' => false,
+ 'null-value' => null,
+ ])->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'key' => 'value', 'key-only' => 'key-only', 'true-value' => 'true-value', 'fetchpriority' => 'low'],
+ ]);
+
+ $this->assertStringContainsString(<<cleanViteManifest($buildDir);
+ }
+
+ public function testItPrefetchesCss()
+ {
+ $manifest = json_decode(file_get_contents(__DIR__.'/fixtures/prefetching-manifest.json'));
+ $buildDir = Str::random();
+ $this->makeViteManifest($manifest, $buildDir);
+ app()->usePublicPath(__DIR__);
+
+ $html = (string) ViteFacade::withEntryPoints(['resources/js/admin.js'])->useBuildDirectory($buildDir)->usePrefetchStrategy('waterfall')->toHtml();
+
+ $expectedAssets = Js::from([
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ConfirmPassword-CDwcgU8E.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/GuestLayout-BY3LC-73.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/TextInput-C8CCB_U_.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/PrimaryButton-DuXwr-9M.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ApplicationLogo-BhIZH06z.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/_plugin-vue_export-helper-DlAUqK2U.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ForgotPassword-B0WWE0BO.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Login-DAFSdGSW.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Register-CfYQbTlA.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/ResetPassword-BNl7a4X1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/VerifyEmail-CyukB_SZ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Dashboard-DM_LxQy2.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/AuthenticatedLayout-DfWF52N1.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Edit-CYV2sXpe.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/DeleteUserForm-B1oHFaVP.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdatePasswordForm-CaeWqGla.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/UpdateProfileInformationForm-CJwkYwQQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/Welcome-D_7l79PQ.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/admin-runtime-import-CRvLQy6v.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'href' => "https://example.com/{$buildDir}/assets/admin-runtime-import-import-DKMIaPXC.js", 'fetchpriority' => 'low'],
+ ['rel' => 'prefetch', 'as' => 'style', 'href' => "https://example.com/{$buildDir}/assets/admin-runtime-import-BlmN0T4U.css", 'fetchpriority' => 'low'],
+ ]);
+ $this->assertSame(<<
+
+ HTML, $html);
+
+ $this->cleanViteManifest($buildDir);
+ }
+
protected function cleanViteManifest($path = 'build')
{
if (file_exists(public_path("{$path}/manifest.json"))) {
diff --git a/tests/Foundation/fixtures/prefetching-manifest.json b/tests/Foundation/fixtures/prefetching-manifest.json
new file mode 100644
index 000000000000..564ee66a4885
--- /dev/null
+++ b/tests/Foundation/fixtures/prefetching-manifest.json
@@ -0,0 +1,284 @@
+{
+ "_ApplicationLogo-BhIZH06z.js": {
+ "file": "assets/ApplicationLogo-BhIZH06z.js",
+ "name": "ApplicationLogo",
+ "imports": [
+ "__plugin-vue_export-helper-DlAUqK2U.js",
+ "_index-BSdK3M0e.js"
+ ]
+ },
+ "_AuthenticatedLayout-DfWF52N1.js": {
+ "file": "assets/AuthenticatedLayout-DfWF52N1.js",
+ "name": "AuthenticatedLayout",
+ "imports": [
+ "_ApplicationLogo-BhIZH06z.js",
+ "_index-BSdK3M0e.js"
+ ]
+ },
+ "_GuestLayout-BY3LC-73.js": {
+ "file": "assets/GuestLayout-BY3LC-73.js",
+ "name": "GuestLayout",
+ "imports": [
+ "_ApplicationLogo-BhIZH06z.js",
+ "_index-BSdK3M0e.js"
+ ]
+ },
+ "_PrimaryButton-DuXwr-9M.js": {
+ "file": "assets/PrimaryButton-DuXwr-9M.js",
+ "name": "PrimaryButton",
+ "imports": [
+ "__plugin-vue_export-helper-DlAUqK2U.js",
+ "_index-BSdK3M0e.js"
+ ]
+ },
+ "_TextInput-C8CCB_U_.js": {
+ "file": "assets/TextInput-C8CCB_U_.js",
+ "name": "TextInput",
+ "imports": [
+ "_index-BSdK3M0e.js"
+ ]
+ },
+ "__plugin-vue_export-helper-DlAUqK2U.js": {
+ "file": "assets/_plugin-vue_export-helper-DlAUqK2U.js",
+ "name": "_plugin-vue_export-helper"
+ },
+ "_index-!~{00f}~.js": {
+ "file": "assets/index-B3s1tYeC.css",
+ "src": "_index-!~{00f}~.js"
+ },
+ "_index-BSdK3M0e.js": {
+ "file": "assets/index-BSdK3M0e.js",
+ "name": "index",
+ "css": [
+ "assets/index-B3s1tYeC.css"
+ ]
+ },
+ "resources/js/Pages/Auth/ConfirmPassword.vue": {
+ "file": "assets/ConfirmPassword-CDwcgU8E.js",
+ "name": "ConfirmPassword",
+ "src": "resources/js/Pages/Auth/ConfirmPassword.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js",
+ "_GuestLayout-BY3LC-73.js",
+ "_TextInput-C8CCB_U_.js",
+ "_PrimaryButton-DuXwr-9M.js",
+ "_ApplicationLogo-BhIZH06z.js",
+ "__plugin-vue_export-helper-DlAUqK2U.js"
+ ]
+ },
+ "resources/js/Pages/Auth/ForgotPassword.vue": {
+ "file": "assets/ForgotPassword-B0WWE0BO.js",
+ "name": "ForgotPassword",
+ "src": "resources/js/Pages/Auth/ForgotPassword.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js",
+ "_GuestLayout-BY3LC-73.js",
+ "_TextInput-C8CCB_U_.js",
+ "_PrimaryButton-DuXwr-9M.js",
+ "_ApplicationLogo-BhIZH06z.js",
+ "__plugin-vue_export-helper-DlAUqK2U.js"
+ ]
+ },
+ "resources/js/Pages/Auth/Login.vue": {
+ "file": "assets/Login-DAFSdGSW.js",
+ "name": "Login",
+ "src": "resources/js/Pages/Auth/Login.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js",
+ "_GuestLayout-BY3LC-73.js",
+ "_TextInput-C8CCB_U_.js",
+ "_PrimaryButton-DuXwr-9M.js",
+ "_ApplicationLogo-BhIZH06z.js",
+ "__plugin-vue_export-helper-DlAUqK2U.js"
+ ]
+ },
+ "resources/js/Pages/Auth/Register.vue": {
+ "file": "assets/Register-CfYQbTlA.js",
+ "name": "Register",
+ "src": "resources/js/Pages/Auth/Register.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js",
+ "_GuestLayout-BY3LC-73.js",
+ "_TextInput-C8CCB_U_.js",
+ "_PrimaryButton-DuXwr-9M.js",
+ "_ApplicationLogo-BhIZH06z.js",
+ "__plugin-vue_export-helper-DlAUqK2U.js"
+ ]
+ },
+ "resources/js/Pages/Auth/ResetPassword.vue": {
+ "file": "assets/ResetPassword-BNl7a4X1.js",
+ "name": "ResetPassword",
+ "src": "resources/js/Pages/Auth/ResetPassword.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js",
+ "_GuestLayout-BY3LC-73.js",
+ "_TextInput-C8CCB_U_.js",
+ "_PrimaryButton-DuXwr-9M.js",
+ "_ApplicationLogo-BhIZH06z.js",
+ "__plugin-vue_export-helper-DlAUqK2U.js"
+ ]
+ },
+ "resources/js/Pages/Auth/VerifyEmail.vue": {
+ "file": "assets/VerifyEmail-CyukB_SZ.js",
+ "name": "VerifyEmail",
+ "src": "resources/js/Pages/Auth/VerifyEmail.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js",
+ "_GuestLayout-BY3LC-73.js",
+ "_PrimaryButton-DuXwr-9M.js",
+ "_ApplicationLogo-BhIZH06z.js",
+ "__plugin-vue_export-helper-DlAUqK2U.js"
+ ]
+ },
+ "resources/js/Pages/Dashboard.vue": {
+ "file": "assets/Dashboard-DM_LxQy2.js",
+ "name": "Dashboard",
+ "src": "resources/js/Pages/Dashboard.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_AuthenticatedLayout-DfWF52N1.js",
+ "_index-BSdK3M0e.js",
+ "_ApplicationLogo-BhIZH06z.js",
+ "__plugin-vue_export-helper-DlAUqK2U.js"
+ ]
+ },
+ "resources/js/Pages/Profile/Edit.vue": {
+ "file": "assets/Edit-CYV2sXpe.js",
+ "name": "Edit",
+ "src": "resources/js/Pages/Profile/Edit.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_AuthenticatedLayout-DfWF52N1.js",
+ "resources/js/Pages/Profile/Partials/DeleteUserForm.vue",
+ "resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue",
+ "resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue",
+ "_index-BSdK3M0e.js",
+ "_ApplicationLogo-BhIZH06z.js",
+ "__plugin-vue_export-helper-DlAUqK2U.js",
+ "_TextInput-C8CCB_U_.js",
+ "_PrimaryButton-DuXwr-9M.js"
+ ]
+ },
+ "resources/js/Pages/Profile/Partials/DeleteUserForm.vue": {
+ "file": "assets/DeleteUserForm-B1oHFaVP.js",
+ "name": "DeleteUserForm",
+ "src": "resources/js/Pages/Profile/Partials/DeleteUserForm.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js",
+ "__plugin-vue_export-helper-DlAUqK2U.js",
+ "_TextInput-C8CCB_U_.js"
+ ]
+ },
+ "resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue": {
+ "file": "assets/UpdatePasswordForm-CaeWqGla.js",
+ "name": "UpdatePasswordForm",
+ "src": "resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js",
+ "_TextInput-C8CCB_U_.js",
+ "_PrimaryButton-DuXwr-9M.js",
+ "__plugin-vue_export-helper-DlAUqK2U.js"
+ ]
+ },
+ "resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue": {
+ "file": "assets/UpdateProfileInformationForm-CJwkYwQQ.js",
+ "name": "UpdateProfileInformationForm",
+ "src": "resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js",
+ "_TextInput-C8CCB_U_.js",
+ "_PrimaryButton-DuXwr-9M.js",
+ "__plugin-vue_export-helper-DlAUqK2U.js"
+ ]
+ },
+ "resources/js/Pages/Welcome.vue": {
+ "file": "assets/Welcome-D_7l79PQ.js",
+ "name": "Welcome",
+ "src": "resources/js/Pages/Welcome.vue",
+ "isDynamicEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js"
+ ]
+ },
+ "resources/js/admin-runtime-import-import.js": {
+ "file": "assets/admin-runtime-import-import-DKMIaPXC.js",
+ "name": "admin-runtime-import-import",
+ "src": "resources/js/admin-runtime-import-import.js",
+ "isDynamicEntry": true
+ },
+ "resources/js/admin-runtime-import.js": {
+ "file": "assets/admin-runtime-import-CRvLQy6v.js",
+ "name": "admin-runtime-import",
+ "src": "resources/js/admin-runtime-import.js",
+ "isDynamicEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js"
+ ],
+ "dynamicImports": [
+ "resources/js/admin-runtime-import-import.js"
+ ],
+ "css": [
+ "assets/admin-runtime-import-BlmN0T4U.css"
+ ]
+ },
+ "resources/js/admin.js": {
+ "file": "assets/admin-Sefg0Q45.js",
+ "name": "admin",
+ "src": "resources/js/admin.js",
+ "isEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js"
+ ],
+ "dynamicImports": [
+ "resources/js/Pages/Auth/ConfirmPassword.vue",
+ "resources/js/Pages/Auth/ForgotPassword.vue",
+ "resources/js/Pages/Auth/Login.vue",
+ "resources/js/Pages/Auth/Register.vue",
+ "resources/js/Pages/Auth/ResetPassword.vue",
+ "resources/js/Pages/Auth/VerifyEmail.vue",
+ "resources/js/Pages/Dashboard.vue",
+ "resources/js/Pages/Profile/Edit.vue",
+ "resources/js/Pages/Profile/Partials/DeleteUserForm.vue",
+ "resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue",
+ "resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue",
+ "resources/js/Pages/Welcome.vue",
+ "resources/js/admin-runtime-import.js"
+ ],
+ "css": [
+ "assets/admin-BctAalm_.css"
+ ]
+ },
+ "resources/js/app.js": {
+ "file": "assets/app-lliD09ip.js",
+ "name": "app",
+ "src": "resources/js/app.js",
+ "isEntry": true,
+ "imports": [
+ "_index-BSdK3M0e.js"
+ ],
+ "dynamicImports": [
+ "resources/js/Pages/Auth/ConfirmPassword.vue",
+ "resources/js/Pages/Auth/ForgotPassword.vue",
+ "resources/js/Pages/Auth/Login.vue",
+ "resources/js/Pages/Auth/Register.vue",
+ "resources/js/Pages/Auth/ResetPassword.vue",
+ "resources/js/Pages/Auth/VerifyEmail.vue",
+ "resources/js/Pages/Dashboard.vue",
+ "resources/js/Pages/Profile/Edit.vue",
+ "resources/js/Pages/Profile/Partials/DeleteUserForm.vue",
+ "resources/js/Pages/Profile/Partials/UpdatePasswordForm.vue",
+ "resources/js/Pages/Profile/Partials/UpdateProfileInformationForm.vue",
+ "resources/js/Pages/Welcome.vue"
+ ]
+ }
+}
+