From 32b8256fb37e43a998082bc6ea8555c4c449e336 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Sun, 15 Mar 2026 16:06:32 -0400 Subject: [PATCH 1/3] Unescape webpack/rspack paths in load and transform --- src/rspack/loaders/load.ts | 4 +- src/rspack/loaders/transform.ts | 5 +- src/utils/webpack-like.ts | 6 ++ src/webpack/loaders/load.ts | 4 +- src/webpack/loaders/transform.ts | 6 +- .../fixtures/hash-path/__test__/build.test.ts | 44 ++++++++++++ test/fixtures/hash-path/bun.config.js | 8 +++ test/fixtures/hash-path/esbuild.config.js | 12 ++++ test/fixtures/hash-path/farm.config.js | 23 +++++++ test/fixtures/hash-path/rollup.config.js | 12 ++++ test/fixtures/hash-path/rspack.config.js | 14 ++++ test/fixtures/hash-path/src/main.js | 3 + test/fixtures/hash-path/src/msg#hash.js | 1 + test/fixtures/hash-path/unplugin.js | 69 +++++++++++++++++++ test/fixtures/hash-path/vite.config.js | 18 +++++ test/fixtures/hash-path/webpack.config.js | 15 ++++ test/fixtures/load/unplugin.js | 1 + 17 files changed, 239 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/hash-path/__test__/build.test.ts create mode 100644 test/fixtures/hash-path/bun.config.js create mode 100644 test/fixtures/hash-path/esbuild.config.js create mode 100644 test/fixtures/hash-path/farm.config.js create mode 100644 test/fixtures/hash-path/rollup.config.js create mode 100644 test/fixtures/hash-path/rspack.config.js create mode 100644 test/fixtures/hash-path/src/main.js create mode 100644 test/fixtures/hash-path/src/msg#hash.js create mode 100644 test/fixtures/hash-path/unplugin.js create mode 100644 test/fixtures/hash-path/vite.config.js create mode 100644 test/fixtures/hash-path/webpack.config.js diff --git a/src/rspack/loaders/load.ts b/src/rspack/loaders/load.ts index acd32fe6..7fd2e653 100644 --- a/src/rspack/loaders/load.ts +++ b/src/rspack/loaders/load.ts @@ -1,7 +1,7 @@ import type { LoaderContext } from '@rspack/core' import type { ResolvedUnpluginOptions } from '../../types' import { normalizeObjectHook } from '../../utils/filter' -import { normalizeAbsolutePath } from '../../utils/webpack-like' +import { normalizeAbsolutePath, unescapeResourcePath } from '../../utils/webpack-like' import { createBuildContext, createContext } from '../context' import { decodeVirtualModuleId, isVirtualModuleId } from '../utils' @@ -16,6 +16,8 @@ export default async function load(this: LoaderContext, source: string, map: any if (isVirtualModuleId(id, plugin)) id = decodeVirtualModuleId(id, plugin) + id = unescapeResourcePath(id) + const context = createContext(this) const { handler } = normalizeObjectHook('load', plugin.load) try { diff --git a/src/rspack/loaders/transform.ts b/src/rspack/loaders/transform.ts index 43cbd344..343e15a9 100644 --- a/src/rspack/loaders/transform.ts +++ b/src/rspack/loaders/transform.ts @@ -1,6 +1,7 @@ import type { LoaderContext } from '@rspack/core' import type { ResolvedUnpluginOptions } from '../../types' import { normalizeObjectHook } from '../../utils/filter' +import { normalizeAbsolutePath, unescapeResourcePath } from '../../utils/webpack-like' import { createBuildContext, createContext } from '../context' export default async function transform( @@ -13,10 +14,10 @@ export default async function transform( if (!plugin?.transform) return callback(null, source, map) - const id = this.resource + const id = normalizeAbsolutePath(unescapeResourcePath(this.resource)) const context = createContext(this) const { handler, filter } = normalizeObjectHook('transform', plugin.transform) - if (!filter(this.resource, source)) + if (!filter(id, source)) return callback(null, source, map) try { diff --git a/src/utils/webpack-like.ts b/src/utils/webpack-like.ts index 3e09f4b8..3bdd546c 100644 --- a/src/utils/webpack-like.ts +++ b/src/utils/webpack-like.ts @@ -49,3 +49,9 @@ export function normalizeAbsolutePath(path: string): string { else return path } + +export function unescapeResourcePath(path: string): string { + return path + .replace(/\0(.)/g, '$1') + .replace(/\u200B#/g, '#') +} diff --git a/src/webpack/loaders/load.ts b/src/webpack/loaders/load.ts index e2fd10fa..34449b0d 100644 --- a/src/webpack/loaders/load.ts +++ b/src/webpack/loaders/load.ts @@ -1,7 +1,7 @@ import type { LoaderContext } from 'webpack' import type { ResolvedUnpluginOptions } from '../../types' import { normalizeObjectHook } from '../../utils/filter' -import { normalizeAbsolutePath } from '../../utils/webpack-like' +import { normalizeAbsolutePath, unescapeResourcePath } from '../../utils/webpack-like' import { createBuildContext, createContext } from '../context' export default async function load(this: LoaderContext, source: string, map: any): Promise { @@ -15,6 +15,8 @@ export default async function load(this: LoaderContext, source: string, map if (id.startsWith(plugin.__virtualModulePrefix)) id = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length)) + id = unescapeResourcePath(id) + const context = createContext(this) const { handler } = normalizeObjectHook('load', plugin.load) const res = await handler.call( diff --git a/src/webpack/loaders/transform.ts b/src/webpack/loaders/transform.ts index 247250d1..0f0c8813 100644 --- a/src/webpack/loaders/transform.ts +++ b/src/webpack/loaders/transform.ts @@ -1,6 +1,7 @@ import type { LoaderContext } from 'webpack' import type { ResolvedUnpluginOptions } from '../../types' import { normalizeObjectHook } from '../../utils/filter' +import { normalizeAbsolutePath, unescapeResourcePath } from '../../utils/webpack-like' import { createBuildContext, createContext } from '../context' export default async function transform(this: LoaderContext, source: string, map: any): Promise { @@ -10,9 +11,10 @@ export default async function transform(this: LoaderContext, source: string if (!plugin?.transform) return callback(null, source, map) + const id = normalizeAbsolutePath(unescapeResourcePath(this.resource)) const context = createContext(this) const { handler, filter } = normalizeObjectHook('transform', plugin.transform) - if (!filter(this.resource, source)) + if (!filter(id, source)) return callback(null, source, map) try { @@ -26,7 +28,7 @@ export default async function transform(this: LoaderContext, source: string }, }, this._compiler!, this._compilation, this, map), context), source, - this.resource, + id, ) if (res == null) diff --git a/test/fixtures/hash-path/__test__/build.test.ts b/test/fixtures/hash-path/__test__/build.test.ts new file mode 100644 index 00000000..c6251c17 --- /dev/null +++ b/test/fixtures/hash-path/__test__/build.test.ts @@ -0,0 +1,44 @@ +import fs from 'node:fs/promises' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { onlyBun } from '../../../utils' + +const r = (...args: string[]) => resolve(__dirname, '../dist', ...args) +const expected = 'it is a msg -> through the load hook -> transform#hash' + +describe('hash-path hooks', () => { + it('vite', async () => { + const content = await fs.readFile(r('vite/main.js.mjs'), 'utf-8') + expect(content).toContain(expected) + }) + + it('rollup', async () => { + const content = await fs.readFile(r('rollup/main.js'), 'utf-8') + expect(content).toContain(expected) + }) + + it('webpack', async () => { + const content = await fs.readFile(r('webpack/main.js'), 'utf-8') + expect(content).toContain(expected) + }) + + it('esbuild', async () => { + const content = await fs.readFile(r('esbuild/main.js'), 'utf-8') + expect(content).toContain(expected) + }) + + it('rspack', async () => { + const content = await fs.readFile(r('rspack/main.js'), 'utf-8') + expect(content).toContain(expected) + }) + + it('farm', async () => { + const content = await fs.readFile(r('farm/main.js'), 'utf-8') + expect(content).toContain(expected) + }) + + onlyBun('bun', async () => { + const content = await fs.readFile(r('bun/main.js'), 'utf-8') + expect(content).toContain(expected) + }) +}) diff --git a/test/fixtures/hash-path/bun.config.js b/test/fixtures/hash-path/bun.config.js new file mode 100644 index 00000000..e37211f9 --- /dev/null +++ b/test/fixtures/hash-path/bun.config.js @@ -0,0 +1,8 @@ +const Bun = require('bun') +const { bun } = require('./unplugin') + +await Bun.build({ + entrypoints: ['./src/main.js'], + outdir: './dist/bun', + plugins: [bun({ msg: 'Bun' })], +}) diff --git a/test/fixtures/hash-path/esbuild.config.js b/test/fixtures/hash-path/esbuild.config.js new file mode 100644 index 00000000..f6c8d4bf --- /dev/null +++ b/test/fixtures/hash-path/esbuild.config.js @@ -0,0 +1,12 @@ +const { build } = require('esbuild') +const { esbuild } = require('./unplugin') + +build({ + entryPoints: ['src/main.js'], + bundle: true, + outdir: 'dist/esbuild', + sourcemap: true, + plugins: [ + esbuild({ msg: 'Esbuild' }), + ], +}) diff --git a/test/fixtures/hash-path/farm.config.js b/test/fixtures/hash-path/farm.config.js new file mode 100644 index 00000000..5943ca73 --- /dev/null +++ b/test/fixtures/hash-path/farm.config.js @@ -0,0 +1,23 @@ +const { farm } = require('./unplugin') + +/** + * @type {import('@farmfe/core').UserConfig} + */ +module.exports = { + compilation: { + persistentCache: false, + input: { + index: './src/main.js', + }, + presetEnv: false, + output: { + entryFilename: 'main.[ext]', + path: './dist/farm', + targetEnv: 'node', + format: 'cjs', + }, + }, + plugins: [ + farm({ msg: 'Farm' }), + ], +} diff --git a/test/fixtures/hash-path/rollup.config.js b/test/fixtures/hash-path/rollup.config.js new file mode 100644 index 00000000..f19be237 --- /dev/null +++ b/test/fixtures/hash-path/rollup.config.js @@ -0,0 +1,12 @@ +const { rollup } = require('./unplugin') + +export default { + input: './src/main.js', + output: { + dir: './dist/rollup', + sourcemap: true, + }, + plugins: [ + rollup({ msg: 'Rollup' }), + ], +} diff --git a/test/fixtures/hash-path/rspack.config.js b/test/fixtures/hash-path/rspack.config.js new file mode 100644 index 00000000..556c61dc --- /dev/null +++ b/test/fixtures/hash-path/rspack.config.js @@ -0,0 +1,14 @@ +const { resolve } = require('node:path') +const { rspack } = require('./unplugin') + +/** @type {import('@rspack/core').Configuration} */ +module.exports = { + mode: 'development', + entry: resolve(__dirname, 'src/main.js'), + output: { + path: resolve(__dirname, 'dist/rspack'), + filename: 'main.js', + }, + plugins: [rspack({ msg: 'Rspack' })], + devtool: 'source-map', +} diff --git a/test/fixtures/hash-path/src/main.js b/test/fixtures/hash-path/src/main.js new file mode 100644 index 00000000..494d1bfe --- /dev/null +++ b/test/fixtures/hash-path/src/main.js @@ -0,0 +1,3 @@ +import msg from 'hash-msg#raw' + +console.log(msg) diff --git a/test/fixtures/hash-path/src/msg#hash.js b/test/fixtures/hash-path/src/msg#hash.js new file mode 100644 index 00000000..7255335f --- /dev/null +++ b/test/fixtures/hash-path/src/msg#hash.js @@ -0,0 +1 @@ +export default 'it is a msg#hash' diff --git a/test/fixtures/hash-path/unplugin.js b/test/fixtures/hash-path/unplugin.js new file mode 100644 index 00000000..b98f321d --- /dev/null +++ b/test/fixtures/hash-path/unplugin.js @@ -0,0 +1,69 @@ +const fs = require('node:fs') +const { resolve } = require('node:path') +const MagicString = require('magic-string') +const { createUnplugin } = require('unplugin') + +const sourceId = 'hash-msg#raw' +const resolvedId = resolve(__dirname, 'src/msg#hash.js') + +function assertUnescapedId(hook, id) { + if (id.includes('\0') || id.includes('\u200B#')) + throw new Error(`${hook} received escaped id: ${JSON.stringify(id)}`) +} + +module.exports = createUnplugin(() => { + return { + name: 'hash-path-hooks', + resolveId(id) { + assertUnescapedId('resolveId', id) + + if (id === sourceId) + return resolvedId + + if (id.includes('hash-msg')) + throw new Error(`resolveId received unexpected id: ${JSON.stringify(id)}`) + }, + loadInclude(id) { + assertUnescapedId('loadInclude', id) + + // Return true so the test always exercises `load`. + return true + }, + load(id) { + assertUnescapedId('load', id) + + const code = fs.readFileSync(id, 'utf-8') + if (id !== resolvedId) + return code + + const s = new MagicString(code) + const index = code.indexOf('msg#hash') + + s.overwrite(index, index + 'msg#hash'.length, 'msg -> through the load hook -> __unplugin__#hash') + return s.toString() + }, + transformInclude(id) { + assertUnescapedId('transformInclude', id) + + // Return true so the test always exercises `transform`. + return true + }, + transform(code, id) { + assertUnescapedId('transform', id) + if (id !== resolvedId) + return code + + const s = new MagicString(code) + const index = code.indexOf('__unplugin__') + + s.overwrite(index, index + '__unplugin__'.length, 'transform') + return { + code: s.toString(), + map: s.generateMap({ + source: id, + includeContent: true, + }), + } + }, + } +}) diff --git a/test/fixtures/hash-path/vite.config.js b/test/fixtures/hash-path/vite.config.js new file mode 100644 index 00000000..309b84a4 --- /dev/null +++ b/test/fixtures/hash-path/vite.config.js @@ -0,0 +1,18 @@ +const { resolve } = require('node:path') +const { vite } = require('./unplugin') + +module.exports = { + root: __dirname, + plugins: [ + vite({ msg: 'Vite' }), + ], + build: { + lib: { + entry: resolve(__dirname, 'src/main.js'), + name: 'main', + fileName: 'main.js', + }, + outDir: 'dist/vite', + sourcemap: true, + }, +} diff --git a/test/fixtures/hash-path/webpack.config.js b/test/fixtures/hash-path/webpack.config.js new file mode 100644 index 00000000..1c5f0d7c --- /dev/null +++ b/test/fixtures/hash-path/webpack.config.js @@ -0,0 +1,15 @@ +const { resolve } = require('node:path') +const { webpack } = require('./unplugin') + +module.exports = { + mode: 'development', + entry: resolve(__dirname, 'src/main.js'), + output: { + path: resolve(__dirname, 'dist/webpack'), + filename: 'main.js', + }, + plugins: [ + webpack({ msg: 'Webpack' }), + ], + devtool: 'source-map', +} diff --git a/test/fixtures/load/unplugin.js b/test/fixtures/load/unplugin.js index 5ed7eaa2..85e7c656 100644 --- a/test/fixtures/load/unplugin.js +++ b/test/fixtures/load/unplugin.js @@ -3,6 +3,7 @@ const MagicString = require('magic-string') const { createUnplugin } = require('unplugin') const targetFileReg = /(?:\/|\\)msg\.js$/ + module.exports = createUnplugin((options) => { return { name: 'load-called-before-transform', From 423e256d31c3740ad6636241296f070b7f979a01 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Sun, 15 Mar 2026 17:07:52 -0400 Subject: [PATCH 2/3] Review comments --- .../fixtures/hash-path/__test__/build.test.ts | 43 ++++++------------- test/fixtures/hash-path/unplugin.js | 4 ++ 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/test/fixtures/hash-path/__test__/build.test.ts b/test/fixtures/hash-path/__test__/build.test.ts index c6251c17..1d9cb4c6 100644 --- a/test/fixtures/hash-path/__test__/build.test.ts +++ b/test/fixtures/hash-path/__test__/build.test.ts @@ -5,37 +5,22 @@ import { onlyBun } from '../../../utils' const r = (...args: string[]) => resolve(__dirname, '../dist', ...args) const expected = 'it is a msg -> through the load hook -> transform#hash' +const cases: Array<[string, string]> = [ + ['vite', 'vite/main.js.mjs'], + ['rollup', 'rollup/main.js'], + ['webpack', 'webpack/main.js'], + ['esbuild', 'esbuild/main.js'], + ['rspack', 'rspack/main.js'], + ['farm', 'farm/main.js'], +] describe('hash-path hooks', () => { - it('vite', async () => { - const content = await fs.readFile(r('vite/main.js.mjs'), 'utf-8') - expect(content).toContain(expected) - }) - - it('rollup', async () => { - const content = await fs.readFile(r('rollup/main.js'), 'utf-8') - expect(content).toContain(expected) - }) - - it('webpack', async () => { - const content = await fs.readFile(r('webpack/main.js'), 'utf-8') - expect(content).toContain(expected) - }) - - it('esbuild', async () => { - const content = await fs.readFile(r('esbuild/main.js'), 'utf-8') - expect(content).toContain(expected) - }) - - it('rspack', async () => { - const content = await fs.readFile(r('rspack/main.js'), 'utf-8') - expect(content).toContain(expected) - }) - - it('farm', async () => { - const content = await fs.readFile(r('farm/main.js'), 'utf-8') - expect(content).toContain(expected) - }) + for (const [name, file] of cases) { + it(name, async () => { + const content = await fs.readFile(r(file), 'utf-8') + expect(content).toContain(expected) + }) + } onlyBun('bun', async () => { const content = await fs.readFile(r('bun/main.js'), 'utf-8') diff --git a/test/fixtures/hash-path/unplugin.js b/test/fixtures/hash-path/unplugin.js index b98f321d..8af0e46f 100644 --- a/test/fixtures/hash-path/unplugin.js +++ b/test/fixtures/hash-path/unplugin.js @@ -38,6 +38,8 @@ module.exports = createUnplugin(() => { const s = new MagicString(code) const index = code.indexOf('msg#hash') + if (index === -1) + throw new Error(`load expected token "msg#hash" in ${JSON.stringify(id)}`) s.overwrite(index, index + 'msg#hash'.length, 'msg -> through the load hook -> __unplugin__#hash') return s.toString() @@ -55,6 +57,8 @@ module.exports = createUnplugin(() => { const s = new MagicString(code) const index = code.indexOf('__unplugin__') + if (index === -1) + throw new Error(`transform expected token "__unplugin__" in ${JSON.stringify(id)}`) s.overwrite(index, index + '__unplugin__'.length, 'transform') return { From 90143740d06a55d5a89890c9c8c5b3a742da01ab Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Mon, 16 Mar 2026 13:09:49 -0400 Subject: [PATCH 3/3] Omit normalization, fix Windows tests --- src/rspack/loaders/transform.ts | 4 ++-- src/webpack/loaders/transform.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/rspack/loaders/transform.ts b/src/rspack/loaders/transform.ts index 343e15a9..e47656ae 100644 --- a/src/rspack/loaders/transform.ts +++ b/src/rspack/loaders/transform.ts @@ -1,7 +1,7 @@ import type { LoaderContext } from '@rspack/core' import type { ResolvedUnpluginOptions } from '../../types' import { normalizeObjectHook } from '../../utils/filter' -import { normalizeAbsolutePath, unescapeResourcePath } from '../../utils/webpack-like' +import { unescapeResourcePath } from '../../utils/webpack-like' import { createBuildContext, createContext } from '../context' export default async function transform( @@ -14,7 +14,7 @@ export default async function transform( if (!plugin?.transform) return callback(null, source, map) - const id = normalizeAbsolutePath(unescapeResourcePath(this.resource)) + const id = unescapeResourcePath(this.resource) const context = createContext(this) const { handler, filter } = normalizeObjectHook('transform', plugin.transform) if (!filter(id, source)) diff --git a/src/webpack/loaders/transform.ts b/src/webpack/loaders/transform.ts index 0f0c8813..7e5a77d6 100644 --- a/src/webpack/loaders/transform.ts +++ b/src/webpack/loaders/transform.ts @@ -1,7 +1,7 @@ import type { LoaderContext } from 'webpack' import type { ResolvedUnpluginOptions } from '../../types' import { normalizeObjectHook } from '../../utils/filter' -import { normalizeAbsolutePath, unescapeResourcePath } from '../../utils/webpack-like' +import { unescapeResourcePath } from '../../utils/webpack-like' import { createBuildContext, createContext } from '../context' export default async function transform(this: LoaderContext, source: string, map: any): Promise { @@ -11,7 +11,7 @@ export default async function transform(this: LoaderContext, source: string if (!plugin?.transform) return callback(null, source, map) - const id = normalizeAbsolutePath(unescapeResourcePath(this.resource)) + const id = unescapeResourcePath(this.resource) const context = createContext(this) const { handler, filter } = normalizeObjectHook('transform', plugin.transform) if (!filter(id, source))