Skip to content

Commit

Permalink
fix: ensure consistent ids across hooks and bundlers (#145)
Browse files Browse the repository at this point in the history
Co-authored-by: Anthony Fu <anthonyfu117@hotmail.com>
  • Loading branch information
lforst and antfu committed Aug 6, 2022
1 parent f197f34 commit 335f403
Show file tree
Hide file tree
Showing 19 changed files with 416 additions and 42 deletions.
6 changes: 6 additions & 0 deletions src/esbuild/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ export function getEsbuildPlugin <UserOptions = {}> (

if (plugin.resolveId) {
onResolve({ filter: onResolveFilter }, async (args) => {
if (initialOptions.external?.includes(args.path)) {
// We don't want to call the `resolveId` hook for external modules, since rollup doesn't do
// that and we want to have consistent behaviour across bundlers
return undefined
}

const isEntry = args.kind === 'entry-point'
const result = await plugin.resolveId!(
args.path,
Expand Down
8 changes: 8 additions & 0 deletions src/globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Flag that is replaced with a boolean during build time.
* __DEV__ is false in the final library output, and it is
* true when the library is ad-hoc transpiled, ie. during tests.
*
* See "tsup.config.ts" and "vitest.config.ts" for more info.
*/
declare const __DEV__: boolean
19 changes: 19 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { isAbsolute, normalize } from 'path'

/**
* Normalizes a given path when it's absolute. Normalizing means returning a new path by converting
* the input path to the native os format. This is useful in cases where we want to normalize
* the `id` argument of a hook. Any absolute ids should be in the default format
* of the operating system. Any relative imports or node_module imports should remain
* untouched.
*
* @param path - Path to normalize.
* @returns a new normalized path.
*/
export function normalizeAbsolutePath (path: string) {
if (isAbsolute(path)) {
return normalize(path)
} else {
return path
}
}
1 change: 0 additions & 1 deletion src/vite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export function getVitePlugin <UserOptions = {}> (
if (rawPlugin.vite) {
Object.assign(plugin, rawPlugin.vite)
}

return plugin
}
}
62 changes: 43 additions & 19 deletions src/webpack/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,24 @@ import { resolve, dirname } from 'path'
import VirtualModulesPlugin from 'webpack-virtual-modules'
import type { ResolvePluginInstance, RuleSetUseItem } from 'webpack'
import type { UnpluginContextMeta, UnpluginInstance, UnpluginFactory, WebpackCompiler, ResolvedUnpluginOptions } from '../types'
import { slash, backSlash } from './utils'
import { normalizeAbsolutePath } from '../utils'
import { createContext } from './context'

const _dirname = typeof __dirname !== 'undefined' ? __dirname : dirname(fileURLToPath(import.meta.url))
const TRANSFORM_LOADER = resolve(_dirname, 'webpack/loaders/transform.js')
const LOAD_LOADER = resolve(_dirname, 'webpack/loaders/load.js')

const TRANSFORM_LOADER = resolve(
_dirname,
__DEV__ ? '../../dist/webpack/loaders/transform' : 'webpack/loaders/transform'
)

const LOAD_LOADER = resolve(
_dirname,
__DEV__ ? '../../dist/webpack/loaders/load' : 'webpack/loaders/load'
)

// We need the prefix of virtual modules to be an absolute path so webpack let's us load them (even if it's made up)
// In the loader we strip the made up prefix path again
const VIRTUAL_MODULE_PREFIX = resolve(process.cwd(), '_virtual_')

export function getWebpackPlugin<UserOptions = {}> (
factory: UnpluginFactory<UserOptions>
Expand All @@ -24,14 +36,12 @@ export function getWebpackPlugin<UserOptions = {}> (
}
}

const virtualModulePrefix = resolve(process.cwd(), '_virtual_')

const rawPlugin = factory(userOptions, meta)
const plugin = Object.assign(
rawPlugin,
{
__unpluginMeta: meta,
__virtualModulePrefix: virtualModulePrefix
__virtualModulePrefix: VIRTUAL_MODULE_PREFIX
}
) as ResolvedUnpluginOptions

Expand All @@ -46,6 +56,8 @@ export function getWebpackPlugin<UserOptions = {}> (
})
})

const externalModules = new Set<string>()

// transform hook
if (plugin.transform) {
const useLoader: RuleSetUseItem[] = [{
Expand All @@ -58,7 +70,7 @@ export function getWebpackPlugin<UserOptions = {}> (
if (data.resource == null) {
return useNone
}
const id = slash(data.resource + (data.resourceQuery || ''))
const id = normalizeAbsolutePath(data.resource + (data.resourceQuery || ''))
if (!plugin.transformInclude || plugin.transformInclude(id)) {
return useLoader
}
Expand Down Expand Up @@ -88,33 +100,39 @@ export function getWebpackPlugin<UserOptions = {}> (
return callback()
}

const id = backSlash(request.request)

// filter out invalid requests
if (id.startsWith(plugin.__virtualModulePrefix)) {
if (normalizeAbsolutePath(request.request).startsWith(plugin.__virtualModulePrefix)) {
return callback()
}

const id = normalizeAbsolutePath(request.request)

const requestContext = (request as unknown as { context: { issuer: string } }).context
const importer = requestContext.issuer !== '' ? requestContext.issuer : undefined
const isEntry = requestContext.issuer === ''

// call hook
const result = await plugin.resolveId!(slash(id), importer, { isEntry })
const resolveIdResult = await plugin.resolveId!(id, importer, { isEntry })

if (result == null) {
if (resolveIdResult == null) {
return callback()
}

let resolved = typeof result === 'string' ? result : result.id
let resolved = typeof resolveIdResult === 'string' ? resolveIdResult : resolveIdResult.id

// TODO: support external
// const isExternal = typeof result === 'string' ? false : result.external === true
const isExternal = typeof resolveIdResult === 'string' ? false : resolveIdResult.external === true
if (isExternal) {
externalModules.add(resolved)
}

// If the resolved module does not exist,
// we treat it as a virtual module
if (!fs.existsSync(resolved)) {
resolved = plugin.__virtualModulePrefix + backSlash(resolved)
resolved = normalizeAbsolutePath(
plugin.__virtualModulePrefix +
encodeURIComponent(resolved) // URI encode id so webpack doesn't think it's part of the path
)

// webpack virtual module should pass in the correct path
plugin.__vfs!.writeModule(resolved, '')
plugin.__vfsModules!.add(resolved)
Expand All @@ -137,10 +155,16 @@ export function getWebpackPlugin<UserOptions = {}> (
}

// load hook
if (plugin.load && plugin.__vfsModules) {
if (plugin.load) {
compiler.options.module.rules.push({
include (id) {
return id != null && plugin.__vfsModules!.has(id)
include (id) { // Don't run load hook for external modules
if (id.startsWith(plugin.__virtualModulePrefix)) {
// If module is a virtual one, we first need to strip its prefix and decode it
const decodedId = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length))
return !externalModules.has(decodedId)
} else {
return !externalModules.has(id)
}
},
enforce: plugin.enforce,
use: [{
Expand Down
9 changes: 6 additions & 3 deletions src/webpack/loaders/load.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { LoaderContext } from 'webpack'
import { UnpluginContext } from '../../types'
import { createContext } from '../context'
import { slash } from '../utils'
import { normalizeAbsolutePath } from '../../utils'

export default async function load (this: LoaderContext<any>, source: string, map: any) {
const callback = this.async()
Expand All @@ -19,10 +19,13 @@ export default async function load (this: LoaderContext<any>, source: string, ma
}

if (id.startsWith(plugin.__virtualModulePrefix)) {
id = id.slice(plugin.__virtualModulePrefix.length)
id = decodeURIComponent(id.slice(plugin.__virtualModulePrefix.length))
}

const res = await plugin.load.call(Object.assign(this._compilation && createContext(this._compilation) as any, context), slash(id))
const res = await plugin.load.call(
Object.assign(this._compilation && createContext(this._compilation) as any, context),
normalizeAbsolutePath(id)
)

if (res == null) {
callback(null, source, map)
Expand Down
9 changes: 0 additions & 9 deletions src/webpack/utils.ts

This file was deleted.

169 changes: 169 additions & 0 deletions test/unit-tests/id-consistency/id-consistency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import * as path from 'path'
import { it, describe, expect, vi, afterEach, Mock } from 'vitest'
import { createUnplugin, UnpluginOptions } from '../../../src'
import { build } from '../utils'

const entryFilePath = path.resolve(__dirname, './test-src/entry.js')

function createUnpluginWithCallback (
resolveIdCallback: UnpluginOptions['resolveId'],
transformIncludeCallback: UnpluginOptions['transformInclude'],
transformCallback: UnpluginOptions['transform'],
loadCallback: UnpluginOptions['load']
) {
return createUnplugin(() => ({
name: 'test-plugin',
resolveId: resolveIdCallback,
transformInclude: transformIncludeCallback,
transform: transformCallback,
load: loadCallback
}))
}

// We extract this check because all bundlers should behave the same
function checkHookCalls (
resolveIdCallback: Mock,
transformIncludeCallback: Mock,
transformCallback: Mock,
loadCallback: Mock
): void {
// Ensure that all bundlers call the hooks the same amount of times
expect(resolveIdCallback).toHaveBeenCalledTimes(4)
expect(transformIncludeCallback).toHaveBeenCalledTimes(4)
expect(transformCallback).toHaveBeenCalledTimes(4)
expect(loadCallback).toHaveBeenCalledTimes(4)

// Ensure that each hook was called with 4 unique ids
expect(new Set(resolveIdCallback.mock.calls.map(call => call[0]))).toHaveLength(4)
expect(new Set(transformIncludeCallback.mock.calls.map(call => call[0]))).toHaveLength(4)
expect(new Set(transformCallback.mock.calls.map(call => call[1]))).toHaveLength(4)
expect(new Set(loadCallback.mock.calls.map(call => call[0]))).toHaveLength(4)

// Ensure that the `resolveId` hook was called with expected values
expect(resolveIdCallback).toHaveBeenCalledWith(entryFilePath, undefined, expect.anything())
expect(resolveIdCallback).toHaveBeenCalledWith('./proxy-export', expect.anything(), expect.anything())
expect(resolveIdCallback).toHaveBeenCalledWith('./sub-folder/named-export', expect.anything(), expect.anything())
expect(resolveIdCallback).toHaveBeenCalledWith('./default-export', expect.anything(), expect.anything())

// Ensure that the `transformInclude`, `transform` and `load` hooks were called with the same (absolute) ids
const ids = transformIncludeCallback.mock.calls.map(call => call[0])
ids.forEach((id) => {
expect(path.isAbsolute(id)).toBe(true)
expect(transformCallback).toHaveBeenCalledWith(expect.anything(), id)
expect(loadCallback).toHaveBeenCalledWith(id)
})
}

describe('id parameter should be consistent accross hooks and plugins', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('vite', async () => {
const mockResolveIdHook = vi.fn(() => undefined)
const mockTransformIncludeHook = vi.fn(() => true)
const mockTransformHook = vi.fn(() => undefined)
const mockLoadHook = vi.fn(() => undefined)

const plugin = createUnpluginWithCallback(
mockResolveIdHook,
mockTransformIncludeHook,
mockTransformHook,
mockLoadHook
).vite

await build.vite({
clearScreen: false,
plugins: [{ ...plugin(), enforce: 'pre' }], // we need to define `enforce` here for the plugin to be run
build: {
lib: {
entry: entryFilePath,
name: 'TestLib'
},
rollupOptions: {
external: ['path']
},
write: false // don't output anything
}
})

checkHookCalls(mockResolveIdHook, mockTransformIncludeHook, mockTransformHook, mockLoadHook)
})

it('rollup', async () => {
const mockResolveIdHook = vi.fn(() => undefined)
const mockTransformIncludeHook = vi.fn(() => true)
const mockTransformHook = vi.fn(() => undefined)
const mockLoadHook = vi.fn(() => undefined)

const plugin = createUnpluginWithCallback(
mockResolveIdHook,
mockTransformIncludeHook,
mockTransformHook,
mockLoadHook
).rollup

await build.rollup({
input: entryFilePath,
plugins: [plugin()],
external: ['path']
})

checkHookCalls(mockResolveIdHook, mockTransformIncludeHook, mockTransformHook, mockLoadHook)
})

it('webpack', async () => {
const mockResolveIdHook = vi.fn(() => undefined)
const mockTransformIncludeHook = vi.fn(() => true)
const mockTransformHook = vi.fn(() => undefined)
const mockLoadHook = vi.fn(() => undefined)

const plugin = createUnpluginWithCallback(
mockResolveIdHook,
mockTransformIncludeHook,
mockTransformHook,
mockLoadHook
).webpack

await new Promise<void>((resolve) => {
build.webpack(
{
entry: entryFilePath,
plugins: [plugin()],
externals: ['path'],
mode: 'production',
target: 'node' // needed for webpack 4 so it doesn't try to "browserify" any node externals and load addtional modules
},
() => {
resolve()
}
)
})

checkHookCalls(mockResolveIdHook, mockTransformIncludeHook, mockTransformHook, mockLoadHook)
})

it('esbuild', async () => {
const mockResolveIdHook = vi.fn(() => undefined)
const mockTransformIncludeHook = vi.fn(() => true)
const mockTransformHook = vi.fn(() => undefined)
const mockLoadHook = vi.fn(() => undefined)

const plugin = createUnpluginWithCallback(
mockResolveIdHook,
mockTransformIncludeHook,
mockTransformHook,
mockLoadHook
).esbuild

await build.esbuild({
entryPoints: [entryFilePath],
plugins: [plugin()],
bundle: true, // actually traverse imports
write: false, // don't pollute console
external: ['path']
})

checkHookCalls(mockResolveIdHook, mockTransformIncludeHook, mockTransformHook, mockLoadHook)
})
})
1 change: 1 addition & 0 deletions test/unit-tests/id-consistency/test-src/default-export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default 'some string'
9 changes: 9 additions & 0 deletions test/unit-tests/id-consistency/test-src/entry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as path from 'path' // test external modules
import { named, proxiedDefault } from './proxy-export'

// just some random code to use the imports
process.stdout.write(JSON.stringify({
named,
proxiedDefault,
path: path.join(__dirname, __filename)
}))

0 comments on commit 335f403

Please sign in to comment.