Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions packages/vite/src/node/__tests__/plugins/asset.spec.ts
Original file line number Diff line number Diff line change
@@ -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()+"'`)
})
})
56 changes: 46 additions & 10 deletions packages/vite/src/node/plugins/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down