Skip to content

Commit 7ada1b6

Browse files
authored
perf: stop build plugins doing work the bundler can filter (#871)
1 parent 1e30766 commit 7ada1b6

7 files changed

Lines changed: 153 additions & 36 deletions

File tree

‎packages/script/src/module.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -828,9 +828,11 @@ export default defineNuxtModule<ModuleOptions>({
828828

829829
constmoduleInstallPromises: Map<string,()=>Promise<boolean>|undefined>=newMap()
830830

831-
addBuildPlugin(NuxtScriptsCheckScripts(),{
832-
dev: true,
833-
})
831+
// Only guards against `await $script` in dev. `addBuildPlugin`'s `dev` option cannot
832+
// express "dev builds only": it skips on `dev: false`, and `nuxt.options.build` is
833+
// always truthy, so the check has to happen here.
834+
if(nuxt.options.dev)
835+
addBuildPlugin(NuxtScriptsCheckScripts())
834836
addBuildPlugin(NuxtScriptBundleTransformer({
835837
nuxt,
836838
scripts: registryScriptsWithImport,

‎packages/script/src/plugins/check-scripts.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import { parseAndWalk } from 'oxc-walker'
33
import{createUnplugin}from'unplugin'
44
import{isVue}from'./util'
55

6-
constVUE_RE=/\.vue/
6+
constVUE_RE=/\.vue(?:\?|$)/
7+
// `$script` is only reachable through a `useScript` call, so the bundler can skip
8+
// every other module before the hook runs.
9+
constUSE_SCRIPT_CODE_MARKER='useScript'
710

811
exportfunctionNuxtScriptsCheckScripts(){
912
returncreateUnplugin(()=>{
@@ -12,12 +15,11 @@ export function NuxtScriptsCheckScripts() {
1215
transform: {
1316
filter: {
1417
id: VUE_RE,
18+
code: USE_SCRIPT_CODE_MARKER,
1519
},
1620
handler(code,id){
1721
if(!isVue(id,{type: ['script']}))
1822
return
19-
if(!code.includes('useScript'))// all integrations should start with useScript*
20-
return
2123

2224
letnameNode: Node|undefined
2325
leterrorNode: Node|undefined

‎packages/script/src/plugins/transform.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,19 @@ import { bundleStorage } from '../assets'
1818
import{logger}from'../logger'
1919
import{getBundleResolve}from'../registry'
2020
import{rewriteScriptUrlsAST}from'./rewrite-ast'
21-
import{isJS,isVue}from'./util'
21+
import{isVue}from'./util'
2222

2323
constSEVEN_DAYS_IN_MS=7*24*60*60*1000
2424

2525
constPROTOCOL_RELATIVE_RE=/^\/\//
26-
constVUE_RE=/\.vue/
27-
constJS_RE=/\.[cm]?[jt]sx?$/
26+
// Ids carry a query in dev and for SFC blocks, so every extension match allows one.
27+
constVUE_RE=/\.vue(?:\?|$)/
28+
constJS_RE=/\.[cm]?[jt]sx?(?:\?|$)/
2829
constTEST_RE=/\.(?:test|spec)\./
30+
// Every integration is called through `useScript` or `useScriptX`, so a module without
31+
// that substring can never need this transform. The bundler applies it, natively where
32+
// it can, so the hook is not called at all for the rest of the graph.
33+
constUSE_SCRIPT_CODE_MARKER='useScript'
2934
constUPPERCASE_RE=/^[A-Z]$/
3035
constUSE_SCRIPT_RE=/^useScript/
3136

@@ -261,11 +266,11 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
261266
include: [VUE_RE,JS_RE],
262267
exclude: [TEST_RE],
263268
},
269+
code: USE_SCRIPT_CODE_MARKER,
264270
},
265271
asynchandler(code,id){
266-
if(!isVue(id,{type: ['template','script']})&&!isJS(id))
267-
return
268-
if(!code.includes('useScript'))// all integrations should start with useScriptX
272+
// A `.vue` id reaches us once per SFC block. Only script and template are ours.
273+
if(VUE_RE.test(id)&&!isVue(id,{type: ['template','script']}))
269274
return
270275

271276
consts=newMagicString(code)

‎packages/script/src/plugins/util.ts‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,3 @@ export function isVue(id: string, opts: { type?: Array<'template' | 'script' | '
3434
// Query `?vue&type=template` (in Webpack or external template)
3535
returntrue
3636
}
37-
38-
constJS_RE=/\.(?:[cm]?j|t)sx?$/
39-
40-
exportfunctionisJS(id: string){
41-
// JavaScript files
42-
const{ pathname }=parseURL(decodeURIComponent(pathToFileURL(id).href))
43-
returnJS_RE.test(pathname)
44-
}

‎test/unit/check-scripts.test.ts‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,16 @@
11
import{describe,expect,it}from'vitest'
22
import{NuxtScriptsCheckScripts}from'../../packages/script/src/plugins/check-scripts'
3+
import{runTransform}from'../utils/unplugin'
34

45
constplugin=NuxtScriptsCheckScripts().vite()asany
56

6-
asyncfunctiontransform(code: string|string[]){
7+
asyncfunctiontransform(code: string|string[],id='file.vue'){
78
consterrors: Error[]=[]
8-
awaitplugin.transform.handler.call(
9-
{
10-
error: (e: Error)=>{
11-
errors.push(e)
12-
},
13-
},
14-
Array.isArray(code) ? code.join('\n') : code,
15-
'file.vue',
16-
)
9+
awaitrunTransform(plugin,{
10+
id,
11+
code: Array.isArray(code) ? code.join('\n') : code,
12+
context: {error: (e: Error)=>{errors.push(e)}},
13+
})
1714
returnerrors
1815
}
1916

@@ -107,3 +104,30 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
107104
expect(awaittransform(code)).toMatchInlineSnapshot(`[]`)
108105
})
109106
})
107+
108+
describe('module scope',()=>{
109+
// Compiled shape of `await $script` in an SFC: the destructure, then `_withAsyncContext`.
110+
constoffending=[
111+
'const { $script } = useScript("/test.js");',
112+
'let __temp, __restore;',
113+
'[__temp, __restore] = _withAsyncContext(() => $script), await __temp, __restore();',
114+
].join('\n')
115+
116+
it.each([
117+
['file.vue','a bare SFC'],
118+
['file.vue?vue&type=script&setup=true&lang.ts','an SFC script block'],
119+
])('inspects %s (%s)',async(id)=>{
120+
expect(awaittransform(offending,id)).not.toEqual([])
121+
})
122+
123+
it.each([
124+
['file.vue?vue&type=style&index=0&lang.css','a style block'],
125+
['file.ts','a plain module'],
126+
])('leaves %s alone (%s)',async(id)=>{
127+
expect(awaittransform(offending,id)).toEqual([])
128+
})
129+
130+
it('skips a component that never calls useScript',async()=>{
131+
expect(awaittransform(`const answer = await fetchAnswer()`)).toEqual([])
132+
})
133+
})

‎test/unit/transform.test.ts‎

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { hash } from 'ohash'
66
import{hasProtocol,joinURL,withBase}from'ufo'
77
import{beforeEach,describe,expect,it,vi}from'vitest'
88
import{NuxtScriptBundleTransformer}from'../../packages/script/src/plugins/transform'
9+
import{runTransform}from'../utils/unplugin'
910

1011
constohash=(awaitvi.importActual<typeofimport('ohash')>('ohash')).hash
1112
vi.mock('ohash',async(og)=>{
@@ -61,16 +62,18 @@ vi.mocked(hasProtocol).mockImplementation(() => true)
6162
// hash receive a URL object, we want to mock it to return the pathname by default
6263
vi.mocked(hash).mockImplementation(src=>src.pathname)
6364

64-
asyncfunctiontransform(code: string|string[],options?: AssetBundlerTransformerOptions){
65+
asyncfunctiontransformId(id: string,code:string,options?: AssetBundlerTransformerOptions){
6566
constplugin=NuxtScriptBundleTransformer({ ...options,nuxt: mockNuxt}).vite()asany
66-
constres=awaitplugin.transform.handler.call(
67-
{},
68-
Array.isArray(code) ? code.join('\n') : code,
69-
'file.js',
70-
)
67+
// Goes through the declared filter, so a filter regression fails here rather than silently
68+
// shipping a plugin that never sees the files it should.
69+
constres=awaitrunTransform(plugin,{ id, code })
7170
returnres?.code
7271
}
7372

73+
asyncfunctiontransform(code: string|string[],options?: AssetBundlerTransformerOptions){
74+
returntransformId('file.js',Array.isArray(code) ? code.join('\n') : code,options)
75+
}
76+
7477
describe('nuxtScriptTransformer',()=>{
7578
it('string arg',async()=>{
7679
vi.mocked(hash).mockImplementationOnce(()=>'beacon.min')
@@ -1314,4 +1317,29 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
13141317
expect(code).toContain('bundle.js')
13151318
})
13161319
})
1320+
1321+
describe('module scope',()=>{
1322+
constbundled=`const instance = useScript('https://example.com/s.js', { bundle: true })`
1323+
1324+
it.each([
1325+
'file.ts',
1326+
'file.mts',
1327+
'file.cts',
1328+
'file.mjs',
1329+
'file.jsx',
1330+
'file.vue',
1331+
'file.vue?vue&type=script&setup=true&lang.ts',
1332+
'file.ts?t=1699999999999',
1333+
])('transforms %s',async(id)=>{
1334+
vi.mocked(hash).mockImplementationOnce(()=>'s')
1335+
expect(awaittransformId(id,bundled)).toContain('/_scripts/')
1336+
})
1337+
1338+
it.each([
1339+
'file.vue?vue&type=style&index=0&lang.css',
1340+
'file.vue?nuxt_component=async',
1341+
])('leaves %s alone',async(id)=>{
1342+
expect(awaittransformId(id,bundled)).toBeUndefined()
1343+
})
1344+
})
13171345
})

‎test/utils/unplugin.ts‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Mirrors unplugin's `StringFilter`. Declared here because `unplugin` is a dependency of
3+
* the module package, not of the test root.
4+
*/
5+
typeFilterPattern=string|RegExp|Array<string|RegExp>
6+
typeStringFilter=FilterPattern|{include?: FilterPattern,exclude?: FilterPattern}
7+
8+
/**
9+
* Calling `plugin.transform.handler` directly skips the declared `transform.filter`,
10+
* so a filter that stops matching the files it should would fail no test. These helpers
11+
* apply the filter first, the way a bundler does.
12+
*
13+
* Semantics mirror unplugin's `createFilterForTransform`: a string pattern is a
14+
* substring test, a RegExp is `test()`, an array is OR, and any `exclude` match vetoes.
15+
*/
16+
17+
functionmatches(pattern: string|RegExp,value: string): boolean{
18+
returntypeofpattern==='string' ? value.includes(pattern) : pattern.test(value)
19+
}
20+
21+
functionmatchesFilter(filter: StringFilter|undefined,value: string): boolean{
22+
if(filter===undefined)
23+
returntrue
24+
if(typeoffilter==='string'||filterinstanceofRegExp)
25+
returnmatches(filter,value)
26+
if(Array.isArray(filter))
27+
returnfilter.some(pattern=>matches(pattern,value))
28+
29+
const{ include, exclude }=filter
30+
if(exclude!==undefined){
31+
constexcluded=Array.isArray(exclude)
32+
? exclude.some(pattern=>matches(pattern,value))
33+
: matches(exclude,value)
34+
if(excluded)
35+
returnfalse
36+
}
37+
if(include===undefined)
38+
returntrue
39+
returnArray.isArray(include)
40+
? include.some(pattern=>matches(pattern,value))
41+
: matches(include,value)
42+
}
43+
44+
/** Would a bundler hand this module to the plugin's transform hook? */
45+
exportfunctiontransformAccepts(plugin: any,id: string,code: string): boolean{
46+
constfilter=plugin.transform?.filter
47+
if(!filter)
48+
returntrue
49+
returnmatchesFilter(filter.id,id)&&matchesFilter(filter.code,code)
50+
}
51+
52+
/**
53+
* Run a transform the way a bundler would: filter, then handler.
54+
* Returns `undefined` when the filter rejects the module, matching a hook that never ran.
55+
*/
56+
exportasyncfunctionrunTransform(
57+
plugin: any,
58+
{ id, code, context ={}}: {id: string,code: string,context?: Record<string,unknown>},
59+
): Promise<any>{
60+
if(!transformAccepts(plugin,id,code))
61+
returnundefined
62+
consthandler=plugin.transform?.handler??plugin.transform
63+
returnhandler.call(context,code,id)
64+
}

0 commit comments

Comments
 (0)