diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 4e6d319e8..bbbbb2a55 100644 --- a/packages/script/src/module.ts +++ b/packages/script/src/module.ts @@ -828,9 +828,11 @@ export default defineNuxtModule({ const moduleInstallPromises: Map Promise | undefined> = new Map() - addBuildPlugin(NuxtScriptsCheckScripts(), { - dev: true, - }) + // Only guards against `await $script` in dev. `addBuildPlugin`'s `dev` option cannot + // express "dev builds only": it skips on `dev: false`, and `nuxt.options.build` is + // always truthy, so the check has to happen here. + if (nuxt.options.dev) + addBuildPlugin(NuxtScriptsCheckScripts()) addBuildPlugin(NuxtScriptBundleTransformer({ nuxt, scripts: registryScriptsWithImport, diff --git a/packages/script/src/plugins/check-scripts.ts b/packages/script/src/plugins/check-scripts.ts index 1d1e723cd..84f431718 100644 --- a/packages/script/src/plugins/check-scripts.ts +++ b/packages/script/src/plugins/check-scripts.ts @@ -3,7 +3,10 @@ import { parseAndWalk } from 'oxc-walker' import { createUnplugin } from 'unplugin' import { isVue } from './util' -const VUE_RE = /\.vue/ +const VUE_RE = /\.vue(?:\?|$)/ +// `$script` is only reachable through a `useScript` call, so the bundler can skip +// every other module before the hook runs. +const USE_SCRIPT_CODE_MARKER = 'useScript' export function NuxtScriptsCheckScripts() { return createUnplugin(() => { @@ -12,12 +15,11 @@ export function NuxtScriptsCheckScripts() { transform: { filter: { id: VUE_RE, + code: USE_SCRIPT_CODE_MARKER, }, handler(code, id) { if (!isVue(id, { type: ['script'] })) return - if (!code.includes('useScript')) // all integrations should start with useScript* - return let nameNode: Node | undefined let errorNode: Node | undefined diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index 7258ad6ac..138dc5f00 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -18,14 +18,19 @@ import { bundleStorage } from '../assets' import { logger } from '../logger' import { getBundleResolve } from '../registry' import { rewriteScriptUrlsAST } from './rewrite-ast' -import { isJS, isVue } from './util' +import { isVue } from './util' const SEVEN_DAYS_IN_MS = 7 * 24 * 60 * 60 * 1000 const PROTOCOL_RELATIVE_RE = /^\/\// -const VUE_RE = /\.vue/ -const JS_RE = /\.[cm]?[jt]sx?$/ +// Ids carry a query in dev and for SFC blocks, so every extension match allows one. +const VUE_RE = /\.vue(?:\?|$)/ +const JS_RE = /\.[cm]?[jt]sx?(?:\?|$)/ const TEST_RE = /\.(?:test|spec)\./ +// Every integration is called through `useScript` or `useScriptX`, so a module without +// that substring can never need this transform. The bundler applies it, natively where +// it can, so the hook is not called at all for the rest of the graph. +const USE_SCRIPT_CODE_MARKER = 'useScript' const UPPERCASE_RE = /^[A-Z]$/ const USE_SCRIPT_RE = /^useScript/ @@ -261,11 +266,11 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti include: [VUE_RE, JS_RE], exclude: [TEST_RE], }, + code: USE_SCRIPT_CODE_MARKER, }, async handler(code, id) { - if (!isVue(id, { type: ['template', 'script'] }) && !isJS(id)) - return - if (!code.includes('useScript')) // all integrations should start with useScriptX + // A `.vue` id reaches us once per SFC block. Only script and template are ours. + if (VUE_RE.test(id) && !isVue(id, { type: ['template', 'script'] })) return const s = new MagicString(code) diff --git a/packages/script/src/plugins/util.ts b/packages/script/src/plugins/util.ts index 1c24cb364..744f0a614 100644 --- a/packages/script/src/plugins/util.ts +++ b/packages/script/src/plugins/util.ts @@ -34,11 +34,3 @@ export function isVue(id: string, opts: { type?: Array<'template' | 'script' | ' // Query `?vue&type=template` (in Webpack or external template) return true } - -const JS_RE = /\.(?:[cm]?j|t)sx?$/ - -export function isJS(id: string) { - // JavaScript files - const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href)) - return JS_RE.test(pathname) -} diff --git a/test/unit/check-scripts.test.ts b/test/unit/check-scripts.test.ts index 9cfeb8f1e..4bb1349f7 100644 --- a/test/unit/check-scripts.test.ts +++ b/test/unit/check-scripts.test.ts @@ -1,19 +1,16 @@ import { describe, expect, it } from 'vitest' import { NuxtScriptsCheckScripts } from '../../packages/script/src/plugins/check-scripts' +import { runTransform } from '../utils/unplugin' const plugin = NuxtScriptsCheckScripts().vite() as any -async function transform(code: string | string[]) { +async function transform(code: string | string[], id = 'file.vue') { const errors: Error[] = [] - await plugin.transform.handler.call( - { - error: (e: Error) => { - errors.push(e) - }, - }, - Array.isArray(code) ? code.join('\n') : code, - 'file.vue', - ) + await runTransform(plugin, { + id, + code: Array.isArray(code) ? code.join('\n') : code, + context: { error: (e: Error) => { errors.push(e) } }, + }) return errors } @@ -107,3 +104,30 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ expect(await transform(code)).toMatchInlineSnapshot(`[]`) }) }) + +describe('module scope', () => { + // Compiled shape of `await $script` in an SFC: the destructure, then `_withAsyncContext`. + const offending = [ + 'const { $script } = useScript("/test.js");', + 'let __temp, __restore;', + '[__temp, __restore] = _withAsyncContext(() => $script), await __temp, __restore();', + ].join('\n') + + it.each([ + ['file.vue', 'a bare SFC'], + ['file.vue?vue&type=script&setup=true&lang.ts', 'an SFC script block'], + ])('inspects %s (%s)', async (id) => { + expect(await transform(offending, id)).not.toEqual([]) + }) + + it.each([ + ['file.vue?vue&type=style&index=0&lang.css', 'a style block'], + ['file.ts', 'a plain module'], + ])('leaves %s alone (%s)', async (id) => { + expect(await transform(offending, id)).toEqual([]) + }) + + it('skips a component that never calls useScript', async () => { + expect(await transform(`const answer = await fetchAnswer()`)).toEqual([]) + }) +}) diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts index 450448281..f2ff412a2 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -6,6 +6,7 @@ import { hash } from 'ohash' import { hasProtocol, joinURL, withBase } from 'ufo' import { beforeEach, describe, expect, it, vi } from 'vitest' import { NuxtScriptBundleTransformer } from '../../packages/script/src/plugins/transform' +import { runTransform } from '../utils/unplugin' const ohash = (await vi.importActual('ohash')).hash vi.mock('ohash', async (og) => { @@ -61,16 +62,18 @@ vi.mocked(hasProtocol).mockImplementation(() => true) // hash receive a URL object, we want to mock it to return the pathname by default vi.mocked(hash).mockImplementation(src => src.pathname) -async function transform(code: string | string[], options?: AssetBundlerTransformerOptions) { +async function transformId(id: string, code: string, options?: AssetBundlerTransformerOptions) { const plugin = NuxtScriptBundleTransformer({ ...options, nuxt: mockNuxt }).vite() as any - const res = await plugin.transform.handler.call( - {}, - Array.isArray(code) ? code.join('\n') : code, - 'file.js', - ) + // Goes through the declared filter, so a filter regression fails here rather than silently + // shipping a plugin that never sees the files it should. + const res = await runTransform(plugin, { id, code }) return res?.code } +async function transform(code: string | string[], options?: AssetBundlerTransformerOptions) { + return transformId('file.js', Array.isArray(code) ? code.join('\n') : code, options) +} + describe('nuxtScriptTransformer', () => { it('string arg', async () => { vi.mocked(hash).mockImplementationOnce(() => 'beacon.min') @@ -1314,4 +1317,29 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({ expect(code).toContain('bundle.js') }) }) + + describe('module scope', () => { + const bundled = `const instance = useScript('https://example.com/s.js', { bundle: true })` + + it.each([ + 'file.ts', + 'file.mts', + 'file.cts', + 'file.mjs', + 'file.jsx', + 'file.vue', + 'file.vue?vue&type=script&setup=true&lang.ts', + 'file.ts?t=1699999999999', + ])('transforms %s', async (id) => { + vi.mocked(hash).mockImplementationOnce(() => 's') + expect(await transformId(id, bundled)).toContain('/_scripts/') + }) + + it.each([ + 'file.vue?vue&type=style&index=0&lang.css', + 'file.vue?nuxt_component=async', + ])('leaves %s alone', async (id) => { + expect(await transformId(id, bundled)).toBeUndefined() + }) + }) }) diff --git a/test/utils/unplugin.ts b/test/utils/unplugin.ts new file mode 100644 index 000000000..d8d8650c2 --- /dev/null +++ b/test/utils/unplugin.ts @@ -0,0 +1,64 @@ +/** + * Mirrors unplugin's `StringFilter`. Declared here because `unplugin` is a dependency of + * the module package, not of the test root. + */ +type FilterPattern = string | RegExp | Array +type StringFilter = FilterPattern | { include?: FilterPattern, exclude?: FilterPattern } + +/** + * Calling `plugin.transform.handler` directly skips the declared `transform.filter`, + * so a filter that stops matching the files it should would fail no test. These helpers + * apply the filter first, the way a bundler does. + * + * Semantics mirror unplugin's `createFilterForTransform`: a string pattern is a + * substring test, a RegExp is `test()`, an array is OR, and any `exclude` match vetoes. + */ + +function matches(pattern: string | RegExp, value: string): boolean { + return typeof pattern === 'string' ? value.includes(pattern) : pattern.test(value) +} + +function matchesFilter(filter: StringFilter | undefined, value: string): boolean { + if (filter === undefined) + return true + if (typeof filter === 'string' || filter instanceof RegExp) + return matches(filter, value) + if (Array.isArray(filter)) + return filter.some(pattern => matches(pattern, value)) + + const { include, exclude } = filter + if (exclude !== undefined) { + const excluded = Array.isArray(exclude) + ? exclude.some(pattern => matches(pattern, value)) + : matches(exclude, value) + if (excluded) + return false + } + if (include === undefined) + return true + return Array.isArray(include) + ? include.some(pattern => matches(pattern, value)) + : matches(include, value) +} + +/** Would a bundler hand this module to the plugin's transform hook? */ +export function transformAccepts(plugin: any, id: string, code: string): boolean { + const filter = plugin.transform?.filter + if (!filter) + return true + return matchesFilter(filter.id, id) && matchesFilter(filter.code, code) +} + +/** + * Run a transform the way a bundler would: filter, then handler. + * Returns `undefined` when the filter rejects the module, matching a hook that never ran. + */ +export async function runTransform( + plugin: any, + { id, code, context = {} }: { id: string, code: string, context?: Record }, +): Promise { + if (!transformAccepts(plugin, id, code)) + return undefined + const handler = plugin.transform?.handler ?? plugin.transform + return handler.call(context, code, id) +}