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'),