Skip to content

Commit 05cd57a

Browse files
committed
feat(rsc): expose scan build observers
(cherry picked from commit 5559882)
1 parent 0d27505 commit 05cd57a

10 files changed

Lines changed: 290 additions & 7 deletions

File tree

packages/plugin-rsc/src/plugin.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,25 @@ type ServerReferenceMeta = {
105105
referenceKey: string
106106
// TODO: tree shake unused server functions
107107
exportNames: string[]
108+
inlineExportNames?: string[]
108109
}
109110

111+
type ScanBuildObserver = (
112+
event:
113+
| {
114+
type: 'reset'
115+
environmentName: string
116+
}
117+
| {
118+
type: 'module'
119+
environmentName: string
120+
code: string
121+
imports: readonly esModuleLexer.ImportSpecifier[]
122+
exports: readonly esModuleLexer.ExportSpecifier[]
123+
info: Rollup.ModuleInfo
124+
},
125+
) => void
126+
110127
const PKG_NAME = '@vitejs/plugin-rsc'
111128
const REACT_SERVER_DOM_NAME = `${PKG_NAME}/vendor/react-server-dom`
112129

@@ -136,6 +153,7 @@ class RscPluginManager {
136153
clientReferenceGroups: Record</* group name*/ string, ClientReferenceMeta[]> =
137154
{}
138155
serverReferenceMetaMap: Record<string, ServerReferenceMeta> = {}
156+
scanBuildObservers: Set<ScanBuildObserver> = new Set()
139157
serverResourcesMetaMap: Record<string, { key: string }> = {}
140158
environmentImportMetaMap: Record<
141159
string, // sourceEnv
@@ -2103,6 +2121,7 @@ function vitePluginUseServer(
21032121
importId: id,
21042122
referenceKey: getNormalizedId(),
21052123
exportNames: [...new Set([...customExportNames, ...exportNames])],
2124+
inlineExportNames: 'names' in result ? result.names : [],
21062125
}
21072126
const importSource = resolvePackage(`${PKG_NAME}/react/rsc/server`)
21082127
output.prepend(
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const action = 1
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import { action } from './actions.js'
2+
export { action as submit } from './actions.js'
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
import './inline.js'
2+
import './module.js'
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export async function inlineAction() {
2+
'use server'
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
'use server'
2+
3+
export async function moduleAction() {}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import path from 'node:path'
2+
import type { ExportSpecifier, ImportSpecifier } from 'es-module-lexer'
3+
import { build } from 'vite'
4+
import { describe, expect, it, vi } from 'vitest'
5+
import type { RscPluginManager } from '../plugin'
6+
import { scanBuildStripPlugin } from './scan'
7+
8+
const root = path.join(import.meta.dirname, 'fixtures/scan-observer')
9+
10+
describe(scanBuildStripPlugin, () => {
11+
it('does not notify observers outside scan builds', async () => {
12+
const observer = vi.fn()
13+
const manager = {
14+
isScanBuild: false,
15+
scanBuildObservers: new Set([observer]),
16+
} as unknown as RscPluginManager
17+
18+
await build({
19+
root,
20+
logLevel: 'silent',
21+
build: {
22+
write: false,
23+
rollupOptions: { input: path.join(root, 'entry.js') },
24+
},
25+
plugins: [scanBuildStripPlugin({ manager })],
26+
})
27+
28+
expect(observer).not.toHaveBeenCalled()
29+
})
30+
31+
it('emits ordered reset and module events with raw scan metadata', async () => {
32+
const observer = vi.fn()
33+
const secondObserver = vi.fn()
34+
const manager = {
35+
isScanBuild: true,
36+
scanBuildObservers: new Set([observer, secondObserver]),
37+
} as unknown as RscPluginManager
38+
39+
await build({
40+
root,
41+
logLevel: 'silent',
42+
build: {
43+
write: false,
44+
rollupOptions: { input: path.join(root, 'entry.js') },
45+
},
46+
plugins: [scanBuildStripPlugin({ manager })],
47+
})
48+
49+
expect(secondObserver.mock.calls).toEqual(observer.mock.calls)
50+
expect(observer.mock.calls[0]![0]).toEqual({
51+
type: 'reset',
52+
environmentName: 'client',
53+
})
54+
55+
const moduleEvents = observer.mock.calls
56+
.map(([event]) => event)
57+
.filter((event) => event.type === 'module')
58+
expect(moduleEvents).toHaveLength(2)
59+
60+
const entryEvent = moduleEvents.find((event) =>
61+
event.info.id.endsWith('/entry.js'),
62+
)
63+
expect(entryEvent).toMatchObject({
64+
type: 'module',
65+
environmentName: 'client',
66+
code: expect.stringContaining("import { action } from './actions.js'"),
67+
info: {
68+
id: expect.stringMatching(/\/entry\.js$/),
69+
importedIds: [expect.stringMatching(/\/actions\.js$/)],
70+
},
71+
})
72+
expect(entryEvent.imports.map((item: ImportSpecifier) => item.n)).toEqual([
73+
'./actions.js',
74+
'./actions.js',
75+
])
76+
expect(entryEvent.exports.map((item: ExportSpecifier) => item.n)).toEqual([
77+
'submit',
78+
])
79+
80+
const actionsEvent = moduleEvents.find((event) =>
81+
event.info.id.endsWith('/actions.js'),
82+
)
83+
expect(actionsEvent).toMatchObject({
84+
type: 'module',
85+
environmentName: 'client',
86+
code: 'export const action = 1\n',
87+
imports: [],
88+
exports: [expect.objectContaining({ n: 'action' })],
89+
info: {
90+
id: expect.stringMatching(/\/actions\.js$/),
91+
importedIds: [],
92+
},
93+
})
94+
})
95+
96+
it('deduplicates module events across shared scan plugins', async () => {
97+
const observer = vi.fn()
98+
const manager = {
99+
isScanBuild: true,
100+
scanBuildObservers: new Set([observer]),
101+
} as unknown as RscPluginManager
102+
103+
await build({
104+
root,
105+
logLevel: 'silent',
106+
build: {
107+
write: false,
108+
rollupOptions: { input: path.join(root, 'entry.js') },
109+
},
110+
plugins: [
111+
scanBuildStripPlugin({ manager }),
112+
scanBuildStripPlugin({ manager }),
113+
],
114+
})
115+
116+
const moduleIds = observer.mock.calls
117+
.map(([event]) => event)
118+
.filter((event) => event.type === 'module')
119+
.map((event) => path.basename(event.info.id))
120+
.sort()
121+
expect(moduleIds).toEqual(['actions.js', 'entry.js'])
122+
})
123+
})

packages/plugin-rsc/src/plugins/scan.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import * as esModuleLexer from 'es-module-lexer'
2-
import { beforeAll, describe, expect, it } from 'vitest'
2+
import { beforeAll, describe, expect, it, vi } from 'vitest'
3+
import vitePluginRsc from '../plugin'
34
import { transformScanBuildStrip } from './scan'
45

56
describe(transformScanBuildStrip, () => {
67
beforeAll(async () => {
78
await esModuleLexer.init
89
})
910

10-
it('basic', async () => {
11+
it('strips modules to their imports', async () => {
1112
const input = `\
1213
import { a } from "a";
1314
import "b";
@@ -27,4 +28,26 @@ export default "foo";
2728
"
2829
`)
2930
})
31+
32+
it('reports the existing lexer result when observed', async () => {
33+
const input = `import { action } from './actions'; export { action as submit }`
34+
const onLexed = vi.fn()
35+
36+
await transformScanBuildStrip(input, onLexed)
37+
38+
expect(onLexed).toHaveBeenCalledOnce()
39+
const [imports, exports] = onLexed.mock.calls[0]!
40+
expect(
41+
imports.map((item: esModuleLexer.ImportSpecifier) => item.n),
42+
).toEqual(['./actions'])
43+
expect(
44+
exports.map((item: esModuleLexer.ExportSpecifier) => item.n),
45+
).toEqual(['submit'])
46+
})
47+
48+
it('keeps the existing scan plugin composition', () => {
49+
expect(
50+
vitePluginRsc().filter((plugin) => plugin.name === 'rsc:scan-strip'),
51+
).toHaveLength(2)
52+
})
3053
})

packages/plugin-rsc/src/plugins/scan.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,35 +4,98 @@ import { walk } from 'estree-walker'
44
import { parseAstAsync, type Plugin } from 'vite'
55
import type { RscPluginManager } from '../plugin'
66

7+
type ScanBuildModule = {
8+
code: string
9+
imports: readonly esModuleLexer.ImportSpecifier[]
10+
exports: readonly esModuleLexer.ExportSpecifier[]
11+
}
12+
13+
const scanBuildModulesMap = new WeakMap<
14+
RscPluginManager,
15+
Map<string, ScanBuildModule>
16+
>()
17+
718
// During scan build, we strip all code but imports to
819
// traverse module graph faster and just discover client/server references.
920
export function scanBuildStripPlugin({
1021
manager,
1122
}: {
1223
manager: RscPluginManager
1324
}): Plugin {
25+
let scanBuildModules = scanBuildModulesMap.get(manager)
26+
if (!scanBuildModules) {
27+
scanBuildModules = new Map()
28+
scanBuildModulesMap.set(manager, scanBuildModules)
29+
}
1430
return {
1531
name: 'rsc:scan-strip',
1632
apply: 'build',
1733
enforce: 'post',
34+
buildStart() {
35+
if (manager.isScanBuild && manager.scanBuildObservers.size > 0) {
36+
scanBuildModules.clear()
37+
for (const observer of manager.scanBuildObservers) {
38+
observer({
39+
type: 'reset',
40+
environmentName: this.environment.name,
41+
})
42+
}
43+
}
44+
},
1845
transform: {
1946
filter: {
2047
id: { exclude: exactRegex('\0rolldown/runtime.js') },
2148
},
22-
async handler(code, _id, _options) {
49+
async handler(code, id, _options) {
2350
if (!manager.isScanBuild) return
24-
const output = await transformScanBuildStrip(code)
25-
return { code: output, map: { mappings: '' } }
51+
return {
52+
code: await transformScanBuildStrip(
53+
code,
54+
manager.scanBuildObservers.size > 0
55+
? (imports, exports) => {
56+
if (!scanBuildModules.has(id)) {
57+
scanBuildModules.set(id, {
58+
code,
59+
imports,
60+
exports,
61+
})
62+
}
63+
}
64+
: undefined,
65+
),
66+
map: { mappings: '' },
67+
}
2668
},
2769
},
70+
moduleParsed(info) {
71+
if (!manager.isScanBuild || manager.scanBuildObservers.size === 0) return
72+
const lexed = scanBuildModules.get(info.id)
73+
if (!lexed) return
74+
scanBuildModules.delete(info.id)
75+
for (const observer of manager.scanBuildObservers) {
76+
observer({
77+
type: 'module',
78+
environmentName: this.environment.name,
79+
info,
80+
...lexed,
81+
})
82+
}
83+
},
2884
}
2985
}
3086

3187
// https://github.com/vitejs/vite/blob/86d2e8be50be535494734f9f5f5236c61626b308/packages/vite/src/node/plugins/importMetaGlob.ts#L113
3288
const importGlobRE = /\bimport\.meta\.glob(?:<\w+>)?\s*\(/g
3389

34-
export async function transformScanBuildStrip(code: string): Promise<string> {
35-
const [imports] = esModuleLexer.parse(code)
90+
export async function transformScanBuildStrip(
91+
code: string,
92+
onLexed?: (
93+
imports: readonly esModuleLexer.ImportSpecifier[],
94+
exports: readonly esModuleLexer.ExportSpecifier[],
95+
) => void | Promise<void>,
96+
): Promise<string> {
97+
const [imports, exports] = esModuleLexer.parse(code)
98+
await onLexed?.(imports, exports)
3699
let output = imports
37100
.map((e) => e.n && `import ${JSON.stringify(e.n)};\n`)
38101
.filter(Boolean)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import path from 'node:path'
2+
import { build } from 'vite'
3+
import { describe, expect, it } from 'vitest'
4+
import { type PluginApi, vitePluginRscMinimal } from '../plugin'
5+
6+
const root = path.join(import.meta.dirname, 'fixtures/server-reference-meta')
7+
8+
describe('server reference metadata', () => {
9+
it('distinguishes inline actions from module-level actions', async () => {
10+
const plugins = vitePluginRscMinimal({
11+
enableActionEncryption: false,
12+
environment: { rsc: 'client' },
13+
})
14+
const manager = (
15+
plugins.find((plugin) => plugin.name === 'rsc:minimal')!.api as PluginApi
16+
).manager
17+
18+
await build({
19+
root,
20+
logLevel: 'silent',
21+
build: {
22+
write: false,
23+
rollupOptions: { input: path.join(root, 'entry.js') },
24+
},
25+
plugins,
26+
})
27+
28+
const inlineMeta = Object.values(manager.serverReferenceMetaMap).find(
29+
(meta) => meta.importId.endsWith('/inline.js'),
30+
)
31+
expect(inlineMeta?.inlineExportNames).toEqual(inlineMeta?.exportNames)
32+
expect(inlineMeta?.inlineExportNames).toEqual([
33+
expect.stringMatching(/inlineAction$/),
34+
])
35+
36+
const moduleMeta = Object.values(manager.serverReferenceMetaMap).find(
37+
(meta) => meta.importId.endsWith('/module.js'),
38+
)
39+
expect(moduleMeta).toMatchObject({
40+
exportNames: ['moduleAction'],
41+
inlineExportNames: [],
42+
})
43+
})
44+
})

0 commit comments

Comments
 (0)