From daaa51be0f46bee097802cbdfc4afe75ca9183ff Mon Sep 17 00:00:00 2001
From: o-m12a
Date: Tue, 10 Mar 2026 00:06:18 +0900
Subject: [PATCH] fix(html): preserve extra attributes on script tags during
build
Script tags in index.html had all non-standard attributes (like
fetchpriority, data-*, integrity) stripped during build because Vite
reconstructs the tags with only a hardcoded set of attributes.
This collects any attributes not managed by Vite (src, type, async,
crossorigin, vite-ignore) and forwards them to the output script tag.
Closes #18061
Co-Authored-By: Claude Opus 4.6
---
packages/vite/src/node/plugins/html.ts | 52 +++++++++++++++++++++---
playground/html/__tests__/html.spec.ts | 12 ++++++
playground/html/scriptFetchPriority.html | 5 +++
playground/html/vite.config.js | 1 +
4 files changed, 65 insertions(+), 5 deletions(-)
create mode 100644 playground/html/scriptFetchPriority.html
diff --git a/packages/vite/src/node/plugins/html.ts b/packages/vite/src/node/plugins/html.ts
index 02305b45387c38..e4d3a7891a1399 100644
--- a/packages/vite/src/node/plugins/html.ts
+++ b/packages/vite/src/node/plugins/html.ts
@@ -174,6 +174,11 @@ export const isAsyncScriptMap: WeakMap<
Map
> = new WeakMap()
+export const scriptExtraAttrsMap: WeakMap<
+ ResolvedConfig,
+ Map>
+> = new WeakMap()
+
export function nodeIsElement(
node: DefaultTreeAdapterMap['node'],
): node is DefaultTreeAdapterMap['element'] {
@@ -222,18 +227,30 @@ export async function traverseHtml(
}
}
+// Attributes that are handled specially by Vite and should not be
+// forwarded to the output script tag as-is.
+const viteHandledScriptAttrs = new Set([
+ 'src',
+ 'type',
+ 'async',
+ 'crossorigin',
+ 'vite-ignore',
+])
+
export function getScriptInfo(node: DefaultTreeAdapterMap['element']): {
src: Token.Attribute | undefined
srcSourceCodeLocation: Token.Location | undefined
isModule: boolean
isAsync: boolean
isIgnored: boolean
+ extraAttrs: Record
} {
let src: Token.Attribute | undefined
let srcSourceCodeLocation: Token.Location | undefined
let isModule = false
let isAsync = false
let isIgnored = false
+ const extraAttrs: Record = {}
for (const p of node.attrs) {
if (p.prefix !== undefined) continue
if (p.name === 'src') {
@@ -247,9 +264,18 @@ export function getScriptInfo(node: DefaultTreeAdapterMap['element']): {
isAsync = true
} else if (p.name === 'vite-ignore') {
isIgnored = true
+ } else if (!viteHandledScriptAttrs.has(p.name)) {
+ extraAttrs[p.name] = p.value === '' ? true : p.value
}
}
- return { src, srcSourceCodeLocation, isModule, isAsync, isIgnored }
+ return {
+ src,
+ srcSourceCodeLocation,
+ isModule,
+ isAsync,
+ isIgnored,
+ extraAttrs,
+ }
}
const attrValueStartRE = /=\s*(.)/
@@ -365,6 +391,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
// Same reason with `htmlInlineProxyPlugin`
isAsyncScriptMap.set(config, new Map())
+ scriptExtraAttrsMap.set(config, new Map())
return {
name: 'vite:build-html',
@@ -438,6 +465,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
let everyScriptIsAsync = true
let someScriptsAreAsync = false
let someScriptsAreDefer = false
+ let mergedExtraAttrs: Record = {}
const assetUrlsPromises: Promise[] = []
@@ -472,8 +500,14 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
// script tags
if (node.nodeName === 'script') {
- const { src, srcSourceCodeLocation, isModule, isAsync, isIgnored } =
- getScriptInfo(node)
+ const {
+ src,
+ srcSourceCodeLocation,
+ isModule,
+ isAsync,
+ isIgnored,
+ extraAttrs,
+ } = getScriptInfo(node)
if (isIgnored) {
removeViteIgnoreAttr(s, node.sourceCodeLocation!)
@@ -531,6 +565,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
everyScriptIsAsync &&= isAsync
someScriptsAreAsync ||= isAsync
someScriptsAreDefer ||= !isAsync
+ mergedExtraAttrs = { ...mergedExtraAttrs, ...extraAttrs }
} else if (url && !isPublicFile) {
if (!isExcludedUrl(url)) {
config.logger.warn(
@@ -682,6 +717,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
})
isAsyncScriptMap.get(config)!.set(id, everyScriptIsAsync)
+ scriptExtraAttrsMap.get(config)!.set(id, mergedExtraAttrs)
if (someScriptsAreAsync && someScriptsAreDefer) {
config.logger.warn(
@@ -777,6 +813,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
chunkOrUrl: OutputChunk | string,
toOutputPath: (filename: string) => string,
isAsync: boolean,
+ extraAttrs: Record = {},
): HtmlTagDescriptor => ({
tag: 'script',
attrs: {
@@ -793,6 +830,7 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
typeof chunkOrUrl === 'string'
? chunkOrUrl
: toOutputPath(chunkOrUrl.fileName),
+ ...extraAttrs,
},
})
@@ -894,6 +932,8 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
toOutputFilePath(filename, 'public')
const isAsync = isAsyncScriptMap.get(config)!.get(normalizedId)!
+ const extraAttrs =
+ scriptExtraAttrsMap.get(config)!.get(normalizedId) ?? {}
let result = html
@@ -923,11 +963,13 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
let assetTags: HtmlTagDescriptor[]
if (canInlineEntry) {
assetTags = imports.map((chunk) =>
- toScriptTag(chunk, toOutputAssetFilePath, isAsync),
+ toScriptTag(chunk, toOutputAssetFilePath, isAsync, extraAttrs),
)
} else {
const { modulePreload } = this.environment.config.build
- assetTags = [toScriptTag(chunk, toOutputAssetFilePath, isAsync)]
+ assetTags = [
+ toScriptTag(chunk, toOutputAssetFilePath, isAsync, extraAttrs),
+ ]
if (modulePreload !== false) {
const resolveDependencies =
typeof modulePreload === 'object' &&
diff --git a/playground/html/__tests__/html.spec.ts b/playground/html/__tests__/html.spec.ts
index 635b028db44e75..ec4ad9ef3fc6f2 100644
--- a/playground/html/__tests__/html.spec.ts
+++ b/playground/html/__tests__/html.spec.ts
@@ -174,6 +174,18 @@ describe.runIf(isBuild)('build', () => {
})
})
+ describe('scriptFetchPriority', () => {
+ beforeAll(async () => {
+ await page.goto(viteTestUrl + '/scriptFetchPriority.html')
+ })
+
+ test('script preserves fetchpriority', async () => {
+ expect(
+ await page.$('head script[type=module][fetchpriority="high"]'),
+ ).toBeTruthy()
+ })
+ })
+
describe('zeroJS', () => {
// Ensure that the modulePreload polyfill is discarded in this case
diff --git a/playground/html/scriptFetchPriority.html b/playground/html/scriptFetchPriority.html
new file mode 100644
index 00000000000000..a605bdb65666f2
--- /dev/null
+++ b/playground/html/scriptFetchPriority.html
@@ -0,0 +1,5 @@
+
+
+scriptFetchPriority.html
+
+
diff --git a/playground/html/vite.config.js b/playground/html/vite.config.js
index 035c9cddb691f3..121c64ca2ddbdb 100644
--- a/playground/html/vite.config.js
+++ b/playground/html/vite.config.js
@@ -12,6 +12,7 @@ export default defineConfig({
nested: resolve(dirname, 'nested/index.html'),
scriptAsync: resolve(dirname, 'scriptAsync.html'),
scriptMixed: resolve(dirname, 'scriptMixed.html'),
+ scriptFetchPriority: resolve(dirname, 'scriptFetchPriority.html'),
emptyAttr: resolve(dirname, 'emptyAttr.html'),
link: resolve(dirname, 'link.html'),
'link/target': resolve(dirname, 'index.html'),