From 4d1342ebe0969cbcfc9c6d7fc5347f85df07df7f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Arnaud=20Barr=C3=A9?= <arnaud.barre@carbometrix.com>
Date: Fri, 12 Jan 2024 09:56:23 +0100
Subject: [PATCH] feat: `build.assetsInlineLimit` callback (#15366)

---
 docs/config/build-options.md                  |  4 +-
 packages/vite/src/node/build.ts               | 12 ++++--
 packages/vite/src/node/constants.ts           |  2 +
 packages/vite/src/node/plugins/asset.ts       | 43 ++++++++++++-------
 playground/worker/vite.config-es.js           |  3 +-
 playground/worker/vite.config-iife.js         |  3 +-
 .../worker/vite.config-relative-base-iife.js  |  3 +-
 .../worker/vite.config-relative-base.js       |  3 +-
 playground/worker/worker-sourcemap-config.js  |  3 +-
 9 files changed, 52 insertions(+), 24 deletions(-)

diff --git a/docs/config/build-options.md b/docs/config/build-options.md
index 757847c41e3d74..e070d4da68cabd 100644
--- a/docs/config/build-options.md
+++ b/docs/config/build-options.md
@@ -82,11 +82,13 @@ Specify the directory to nest generated assets under (relative to `build.outDir`
 
 ## build.assetsInlineLimit
 
-- **Type:** `number`
+- **Type:** `number` | `((filePath: string, content: Buffer) => boolean | undefined)`
 - **Default:** `4096` (4 KiB)
 
 Imported or referenced assets that are smaller than this threshold will be inlined as base64 URLs to avoid extra http requests. Set to `0` to disable inlining altogether.
 
+If a callback is passed, a boolean can be returned to opt-in or opt-out. If nothing is returned the default logic applies.
+
 Git LFS placeholders are automatically excluded from inlining because they do not contain the content of the file they represent.
 
 ::: tip Note
diff --git a/packages/vite/src/node/build.ts b/packages/vite/src/node/build.ts
index aa71f7b4bc1312..69d5ee136645b7 100644
--- a/packages/vite/src/node/build.ts
+++ b/packages/vite/src/node/build.ts
@@ -45,7 +45,11 @@ import { initDepsOptimizer } from './optimizer'
 import { loadFallbackPlugin } from './plugins/loadFallback'
 import { findNearestPackageData } from './packages'
 import type { PackageCache } from './packages'
-import { ESBUILD_MODULES_TARGET, VERSION } from './constants'
+import {
+  DEFAULT_ASSETS_INLINE_LIMIT,
+  ESBUILD_MODULES_TARGET,
+  VERSION,
+} from './constants'
 import { resolveChokidarOptions } from './watch'
 import { completeSystemWrapPlugin } from './plugins/completeSystemWrap'
 import { mergeConfig } from './publicUtils'
@@ -101,7 +105,9 @@ export interface BuildOptions {
    * base64 strings. Default limit is `4096` (4 KiB). Set to `0` to disable.
    * @default 4096
    */
-  assetsInlineLimit?: number
+  assetsInlineLimit?:
+    | number
+    | ((filePath: string, content: Buffer) => boolean | undefined)
   /**
    * Whether to code-split CSS. When enabled, CSS in async chunks will be
    * inlined as strings in the chunk and inserted via dynamically created
@@ -325,7 +331,7 @@ export function resolveBuildOptions(
   const defaultBuildOptions: BuildOptions = {
     outDir: 'dist',
     assetsDir: 'assets',
-    assetsInlineLimit: 4096,
+    assetsInlineLimit: DEFAULT_ASSETS_INLINE_LIMIT,
     cssCodeSplit: !raw?.lib,
     sourcemap: false,
     rollupOptions: {},
diff --git a/packages/vite/src/node/constants.ts b/packages/vite/src/node/constants.ts
index 41b99fa52a6489..384b6694cd9cf5 100644
--- a/packages/vite/src/node/constants.ts
+++ b/packages/vite/src/node/constants.ts
@@ -156,4 +156,6 @@ export const DEFAULT_DEV_PORT = 5173
 
 export const DEFAULT_PREVIEW_PORT = 4173
 
+export const DEFAULT_ASSETS_INLINE_LIMIT = 4096
+
 export const METADATA_FILENAME = '_metadata.json'
diff --git a/packages/vite/src/node/plugins/asset.ts b/packages/vite/src/node/plugins/asset.ts
index 9295bb9a4407dc..df5fdc1eb1aa21 100644
--- a/packages/vite/src/node/plugins/asset.ts
+++ b/packages/vite/src/node/plugins/asset.ts
@@ -26,7 +26,7 @@ import {
   removeLeadingSlash,
   withTrailingSlash,
 } from '../utils'
-import { FS_PREFIX } from '../constants'
+import { DEFAULT_ASSETS_INLINE_LIMIT, FS_PREFIX } from '../constants'
 import type { ModuleGraph } from '../server/moduleGraph'
 
 // referenceId is base64url but replaces - with $
@@ -325,7 +325,7 @@ async function fileToBuiltUrl(
   config: ResolvedConfig,
   pluginContext: PluginContext,
   skipPublicCheck = false,
-  shouldInline?: boolean,
+  forceInline?: boolean,
 ): Promise<string> {
   if (!skipPublicCheck && checkPublicFile(id, config)) {
     return publicFileToBuiltUrl(id, config)
@@ -340,18 +340,8 @@ async function fileToBuiltUrl(
   const file = cleanUrl(id)
   const content = await fsp.readFile(file)
 
-  if (shouldInline == null) {
-    shouldInline =
-      !!config.build.lib ||
-      // Don't inline SVG with fragments, as they are meant to be reused
-      (!(file.endsWith('.svg') && id.includes('#')) &&
-        !file.endsWith('.html') &&
-        content.length < Number(config.build.assetsInlineLimit) &&
-        !isGitLfsPlaceholder(content))
-  }
-
   let url: string
-  if (shouldInline) {
+  if (shouldInline(config, file, id, content, forceInline)) {
     if (config.build.lib && isGitLfsPlaceholder(content)) {
       config.logger.warn(
         colors.yellow(`Inlined file ${id} was not downloaded via Git LFS`),
@@ -392,7 +382,7 @@ export async function urlToBuiltUrl(
   importer: string,
   config: ResolvedConfig,
   pluginContext: PluginContext,
-  shouldInline?: boolean,
+  forceInline?: boolean,
 ): Promise<string> {
   if (checkPublicFile(url, config)) {
     return publicFileToBuiltUrl(url, config)
@@ -407,10 +397,33 @@ export async function urlToBuiltUrl(
     pluginContext,
     // skip public check since we just did it above
     true,
-    shouldInline,
+    forceInline,
   )
 }
 
+const shouldInline = (
+  config: ResolvedConfig,
+  file: string,
+  id: string,
+  content: Buffer,
+  forceInline: boolean | undefined,
+): boolean => {
+  if (config.build.lib) return true
+  if (forceInline !== undefined) return forceInline
+  let limit: number
+  if (typeof config.build.assetsInlineLimit === 'function') {
+    const userShouldInline = config.build.assetsInlineLimit(file, content)
+    if (userShouldInline != null) return userShouldInline
+    limit = DEFAULT_ASSETS_INLINE_LIMIT
+  } else {
+    limit = Number(config.build.assetsInlineLimit)
+  }
+  if (file.endsWith('.html')) return false
+  // Don't inline SVG with fragments, as they are meant to be reused
+  if (file.endsWith('.svg') && id.includes('#')) return false
+  return content.length < limit && !isGitLfsPlaceholder(content)
+}
+
 const nestedQuotesRE = /"[^"']*'[^"]*"|'[^'"]*"[^']*'/
 
 // Inspired by https://github.com/iconify/iconify/blob/main/packages/utils/src/svg/url.ts
diff --git a/playground/worker/vite.config-es.js b/playground/worker/vite.config-es.js
index 995902373d72a5..eba1f7e2f1bd76 100644
--- a/playground/worker/vite.config-es.js
+++ b/playground/worker/vite.config-es.js
@@ -21,7 +21,8 @@ export default defineConfig({
   },
   build: {
     outDir: 'dist/es',
-    assetsInlineLimit: 100, // keep SVG as assets URL
+    assetsInlineLimit: (filePath) =>
+      filePath.endsWith('.svg') ? false : undefined,
     rollupOptions: {
       output: {
         assetFileNames: 'assets/[name].[ext]',
diff --git a/playground/worker/vite.config-iife.js b/playground/worker/vite.config-iife.js
index 7ac8220e25a7e4..3d6d0de8a170e5 100644
--- a/playground/worker/vite.config-iife.js
+++ b/playground/worker/vite.config-iife.js
@@ -22,7 +22,8 @@ export default defineConfig({
   },
   build: {
     outDir: 'dist/iife',
-    assetsInlineLimit: 100, // keep SVG as assets URL
+    assetsInlineLimit: (filePath) =>
+      filePath.endsWith('.svg') ? false : undefined,
     manifest: true,
     rollupOptions: {
       output: {
diff --git a/playground/worker/vite.config-relative-base-iife.js b/playground/worker/vite.config-relative-base-iife.js
index 0ea1a872d59e82..657d7b3094acf6 100644
--- a/playground/worker/vite.config-relative-base-iife.js
+++ b/playground/worker/vite.config-relative-base-iife.js
@@ -21,7 +21,8 @@ export default defineConfig(({ isPreview }) => ({
   },
   build: {
     outDir: 'dist/relative-base-iife',
-    assetsInlineLimit: 100, // keep SVG as assets URL
+    assetsInlineLimit: (filePath) =>
+      filePath.endsWith('.svg') ? false : undefined,
     rollupOptions: {
       output: {
         assetFileNames: 'other-assets/[name]-[hash].[ext]',
diff --git a/playground/worker/vite.config-relative-base.js b/playground/worker/vite.config-relative-base.js
index d5935bb3ef9132..f4f22cc12e0cd9 100644
--- a/playground/worker/vite.config-relative-base.js
+++ b/playground/worker/vite.config-relative-base.js
@@ -21,7 +21,8 @@ export default defineConfig(({ isPreview }) => ({
   },
   build: {
     outDir: 'dist/relative-base',
-    assetsInlineLimit: 100, // keep SVG as assets URL
+    assetsInlineLimit: (filePath) =>
+      filePath.endsWith('.svg') ? false : undefined,
     rollupOptions: {
       output: {
         assetFileNames: 'other-assets/[name]-[hash].[ext]',
diff --git a/playground/worker/worker-sourcemap-config.js b/playground/worker/worker-sourcemap-config.js
index 25dd8aa83b70a0..2c4e40e78d2a5a 100644
--- a/playground/worker/worker-sourcemap-config.js
+++ b/playground/worker/worker-sourcemap-config.js
@@ -35,7 +35,8 @@ export default (sourcemap) => {
     },
     build: {
       outDir: `dist/iife-${typeName}/`,
-      assetsInlineLimit: 100, // keep SVG as assets URL
+      assetsInlineLimit: (filePath) =>
+        filePath.endsWith('.svg') ? false : undefined,
       sourcemap: sourcemap,
       rollupOptions: {
         output: {