diff --git a/packages/vite/src/node/__tests__/plugins/asset.spec.ts b/packages/vite/src/node/__tests__/plugins/asset.spec.ts new file mode 100644 index 00000000000000..dcf81c843c6461 --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/asset.spec.ts @@ -0,0 +1,57 @@ +import MagicString from 'magic-string' +import { describe, expect, test } from 'vitest' +import { assetUrlRE, writeRuntimeAssetReplacement } from '../../plugins/asset' + +describe('writeRuntimeAssetReplacement', () => { + // Regression test for #22304: when the asset placeholder sits inside a + // string literal and is preceded by a unary operator (typeof, void, !, + // delete), the bare `"+runtime+"` substitution produces output that + // parses as `(typeof "") + runtime + ""` instead of evaluating typeof + // over the concatenated string. The fix wraps in parens. + function rewrite(code: string, runtime: string): string { + const s = new MagicString(code) + assetUrlRE.lastIndex = 0 + let match: RegExpExecArray | null + while ((match = assetUrlRE.exec(code))) { + writeRuntimeAssetReplacement(s, code, match, match[0], runtime) + } + return s.toString() + } + + test('wraps quoted placeholder so typeof binds to the runtime expression', () => { + const out = rewrite( + 'if (typeof "__VITE_ASSET__abc__" === "string") {}', + 'new URL("./hero.png", import.meta.url).href', + ) + expect(out).toBe( + 'if (typeof (""+new URL("./hero.png", import.meta.url).href+"") === "string") {}', + ) + }) + + test('uses parens when single quotes wrap the placeholder exactly', () => { + const out = rewrite("var x = '__VITE_ASSET__abc__'", 'r()') + expect(out).toBe('var x = (""+r()+"")') + }) + + test('uses parens when backticks wrap the placeholder exactly', () => { + const out = rewrite('var x = `__VITE_ASSET__abc__`', 'r()') + expect(out).toBe('var x = (""+r()+"")') + }) + + test('falls back to the legacy substitution outside string contexts', () => { + // CSS-in-JS shape: the placeholder is inside a string but immediately + // adjacent to non-quote characters (url(...) wrapper). + const out = rewrite( + 'var s = ".foo{background:url(__VITE_ASSET__abc__)}"', + 'r()', + ) + expect(out).toBe('var s = ".foo{background:url("+r()+")}"') + }) + + test('does not pair mismatched surrounding quotes', () => { + // before='"' and after="'" should NOT be treated as a wrapped string, + // so the bare substitution applies and the surrounding chars stay. + const out = rewrite(`var x = "__VITE_ASSET__abc__'`, 'r()') + expect(out).toBe(`var x = ""+r()+"'`) + }) +}) diff --git a/packages/vite/src/node/plugins/asset.ts b/packages/vite/src/node/plugins/asset.ts index 102e1c3125d2a3..7b4105ee0553d8 100644 --- a/packages/vite/src/node/plugins/asset.ts +++ b/packages/vite/src/node/plugins/asset.ts @@ -73,6 +73,34 @@ export function registerCustomMime(): void { mrmime.mimes.eot = 'application/vnd.ms-fontobject' } +// When the placeholder sits inside a string literal (`"__VITE_ASSET__abc__"`), +// expand the rewrite range to include the surrounding quotes and emit a +// parenthesized string expression. Without parentheses, a leading unary +// operator like `typeof "..."` parses as `(typeof "") + runtime + ""`, +// which evaluates to a concatenated string instead of `"string"`. See #22304. +// Exported for unit testing only. +export function writeRuntimeAssetReplacement( + s: MagicString, + code: string, + match: RegExpExecArray, + full: string, + runtime: string, +): void { + const start = match.index + const end = match.index + full.length + const before = code[start - 1] + const after = code[end] + if ( + (before === '"' && after === '"') || + (before === "'" && after === "'") || + (before === '`' && after === '`') + ) { + s.update(start - 1, end + 1, `(""+${runtime}+"")`) + } else { + s.update(start, end, `"+${runtime}+"`) + } +} + export function renderAssetUrlInJS( pluginContext: PluginContext, chunk: RenderedChunk, @@ -111,11 +139,15 @@ export function renderAssetUrlInJS( 'js', toRelativeRuntime, ) - const replacementString = - typeof replacement === 'string' - ? JSON.stringify(encodeURIPath(replacement)).slice(1, -1) - : `"+${replacement.runtime}+"` - s.update(match.index, match.index + full.length, replacementString) + if (typeof replacement === 'string') { + s.update( + match.index, + match.index + full.length, + JSON.stringify(encodeURIPath(replacement)).slice(1, -1), + ) + } else { + writeRuntimeAssetReplacement(s, code, match, full, replacement.runtime) + } } // Replace __VITE_PUBLIC_ASSET__5aA0Ddc0__ with absolute paths @@ -136,11 +168,15 @@ export function renderAssetUrlInJS( 'js', toRelativeRuntime, ) - const replacementString = - typeof replacement === 'string' - ? JSON.stringify(encodeURIPath(replacement)).slice(1, -1) - : `"+${replacement.runtime}+"` - s.update(match.index, match.index + full.length, replacementString) + if (typeof replacement === 'string') { + s.update( + match.index, + match.index + full.length, + JSON.stringify(encodeURIPath(replacement)).slice(1, -1), + ) + } else { + writeRuntimeAssetReplacement(s, code, match, full, replacement.runtime) + } } return s