From 53e40b9461f8bbbc5ff2ef7104866568c5c8a25b Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 18 Aug 2026 16:15:03 +1000 Subject: [PATCH 1/4] perf: cut always-on build plugin work `check-scripts` only exists to error on `await $script` while developing, but it ran in production builds too. `addBuildPlugin(plugin, { dev: true })` reads as dev only and is not: kit skips a plugin on `dev: false`, never on `dev: true`, so the flag filtered nothing. Guarded on `nuxt.options.dev` instead. The bundler transformer's id filter admits every JS and Vue module in the graph, so its handler runs across the whole build, twice. It called `isVue`/`isJS` first, each parsing the id as a URL, before the `code.includes('useScript')` check that rejects nearly everything. Reordered. --- packages/script/src/module.ts | 8 +++++--- packages/script/src/plugins/transform.ts | 6 ++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts index 4e6d319e..bbbbb2a5 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/transform.ts b/packages/script/src/plugins/transform.ts index 7258ad6a..7308dcaf 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -263,10 +263,12 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti }, }, async handler(code, id) { - if (!isVue(id, { type: ['template', 'script'] }) && !isJS(id)) - return + // Cheapest check first: the id filter above still lets every JS and Vue module in the + // graph reach this handler, and `isVue`/`isJS` each parse the id as a URL. if (!code.includes('useScript')) // all integrations should start with useScriptX return + if (!isVue(id, { type: ['template', 'script'] }) && !isJS(id)) + return const s = new MagicString(code) const deferredOps: (() => Promise)[] = [] From ade87b1b8ccbbfbed5e3273feb58d3b67a31ca77 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 18 Aug 2026 16:49:04 +1000 Subject: [PATCH 2/4] perf: let the bundler filter build plugin work Both plugins re-checked in the handler what their declared filter could express. - moved the `useScript` substring test into `filter.code`. unplugin hands that to the bundler natively where supported, so the hook is not called at all for the rest of the graph, and applies it itself elsewhere - dropped `isJS`. Its regex `/\.(?:[cm]?j|t)sx?$/` matches `mj` or `t`, never `mt`, so a `.mts` or `.cts` module passed the id filter, failed `isJS` and `isVue`, and was silently skipped. `useScript` in a `.mts` file was never transformed - the remaining `isVue` call is not redundant: `/\.vue/` admits `?vue&type=style` and `?nuxt_component`, which only `isVue` rejects. Narrowed to `.vue` ids - anchored both extension patterns to end-or-query, so an id carrying a query still matches --- packages/script/src/plugins/check-scripts.ts | 8 +++-- packages/script/src/plugins/transform.ts | 19 +++++----- packages/script/src/plugins/util.ts | 8 ----- test/unit/transform.test.ts | 37 ++++++++++++++++---- 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/packages/script/src/plugins/check-scripts.ts b/packages/script/src/plugins/check-scripts.ts index 1d1e723c..84f43171 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 7308dcaf..138dc5f0 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,13 +266,11 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti include: [VUE_RE, JS_RE], exclude: [TEST_RE], }, + code: USE_SCRIPT_CODE_MARKER, }, async handler(code, id) { - // Cheapest check first: the id filter above still lets every JS and Vue module in the - // graph reach this handler, and `isVue`/`isJS` each parse the id as a URL. - if (!code.includes('useScript')) // all integrations should start with useScriptX - return - if (!isVue(id, { type: ['template', 'script'] }) && !isJS(id)) + // 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 1c24cb36..744f0a61 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/transform.test.ts b/test/unit/transform.test.ts index 45044828..cd6ad5a6 100644 --- a/test/unit/transform.test.ts +++ b/test/unit/transform.test.ts @@ -61,16 +61,16 @@ 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', - ) + const res = await plugin.transform.handler.call({}, code, id) 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 +1314,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() + }) + }) }) From 46bd545005567efbc1e172ec6e7ec4b5d1cf62be Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 18 Aug 2026 17:35:23 +1000 Subject: [PATCH 3/4] test: run build plugin transforms through their declared filter The tests called `plugin.transform.handler` directly, so the declared `transform.filter` was never exercised. A filter that stopped matching the files it should would have failed nothing. `runTransform` applies the id and code filters first, the way a bundler does. Reverting `JS_RE` to the old pattern now fails exactly the `.mts`, `.cts` and queried-id cases, and nothing else. --- test/unit/check-scripts.test.ts | 44 +++++++++++++++++------ test/unit/transform.test.ts | 5 ++- test/utils/unplugin.ts | 62 +++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 11 deletions(-) create mode 100644 test/utils/unplugin.ts diff --git a/test/unit/check-scripts.test.ts b/test/unit/check-scripts.test.ts index 9cfeb8f1..4bb1349f 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 cd6ad5a6..f2ff412a 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) => { @@ -63,7 +64,9 @@ vi.mocked(hash).mockImplementation(src => src.pathname) 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({}, code, id) + // 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 } diff --git a/test/utils/unplugin.ts b/test/utils/unplugin.ts new file mode 100644 index 00000000..64439f3e --- /dev/null +++ b/test/utils/unplugin.ts @@ -0,0 +1,62 @@ +/** 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) +} From 436081b42a6cc1eaeb0856e69aa923577f0973ce Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 18 Aug 2026 17:36:00 +1000 Subject: [PATCH 4/4] style: fix jsdoc block format --- test/utils/unplugin.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/utils/unplugin.ts b/test/utils/unplugin.ts index 64439f3e..d8d8650c 100644 --- a/test/utils/unplugin.ts +++ b/test/utils/unplugin.ts @@ -1,5 +1,7 @@ -/** Mirrors unplugin's `StringFilter`. Declared here because `unplugin` is a dependency of - * the module package, not of the test root. */ +/** + * 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 }