Skip to content

Commit 77c1b1b

Browse files
hi-ogawaclaude
andauthored
feat(rsc): add resolved-id proxy for virtual modules + document ?direct limitation (#1050)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 58d21e6 commit 77c1b1b

File tree

7 files changed

+347
-2
lines changed

7 files changed

+347
-2
lines changed

packages/plugin-rsc/e2e/basic.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,4 +1620,74 @@ function defineTest(f: Fixture) {
16201620
'test-tree-shake2:lib-client1|lib-server1',
16211621
)
16221622
})
1623+
1624+
test('virtual module with use client', async ({ page }) => {
1625+
await page.goto(f.url())
1626+
await waitForHydration(page)
1627+
1628+
// Test that the virtual client component renders and works
1629+
await expect(page.getByTestId('test-virtual-client')).toHaveText(
1630+
'test-virtual-client: not-clicked',
1631+
)
1632+
await page.getByTestId('test-virtual-client').click()
1633+
await expect(page.getByTestId('test-virtual-client')).toHaveText(
1634+
'test-virtual-client: clicked',
1635+
)
1636+
})
1637+
1638+
test('virtual css module', async ({ page }) => {
1639+
await page.goto(f.url())
1640+
await waitForHydration(page)
1641+
1642+
// Server CSS (loaded via <link>)
1643+
// Query-aware: works in both dev and build
1644+
await expect(page.locator('.test-virtual-style-server-query')).toHaveCSS(
1645+
'color',
1646+
'rgb(50, 100, 150)',
1647+
)
1648+
// Exact-match: fails via <link> in dev (Vite limitation), works in build
1649+
await expect(page.locator('.test-virtual-style-server-exact')).toHaveCSS(
1650+
'color',
1651+
f.mode === 'dev' ? 'rgb(0, 0, 0)' : 'rgb(200, 100, 50)',
1652+
)
1653+
1654+
// Client CSS (loaded via JS import, HMR injects styles)
1655+
// Both patterns work because no ?direct is involved in JS imports
1656+
await expect(page.locator('.test-virtual-style-client-query')).toHaveCSS(
1657+
'color',
1658+
'rgb(50, 150, 100)',
1659+
)
1660+
await expect(page.locator('.test-virtual-style-client-exact')).toHaveCSS(
1661+
'color',
1662+
'rgb(200, 50, 100)',
1663+
)
1664+
})
1665+
1666+
testNoJs('virtual css module @nojs', async ({ page }) => {
1667+
await page.goto(f.url())
1668+
1669+
// Server CSS (loaded via <link>)
1670+
// Query-aware: works in both dev and build
1671+
await expect(page.locator('.test-virtual-style-server-query')).toHaveCSS(
1672+
'color',
1673+
'rgb(50, 100, 150)',
1674+
)
1675+
// Exact-match: fails via <link> in dev (Vite limitation)
1676+
await expect(page.locator('.test-virtual-style-server-exact')).toHaveCSS(
1677+
'color',
1678+
f.mode === 'dev' ? 'rgb(0, 0, 0)' : 'rgb(200, 100, 50)',
1679+
)
1680+
1681+
// Client CSS (loaded via <link> in noJS mode)
1682+
// Query-aware: works in both dev and build
1683+
await expect(page.locator('.test-virtual-style-client-query')).toHaveCSS(
1684+
'color',
1685+
'rgb(50, 150, 100)',
1686+
)
1687+
// Exact-match: fails via <link> in dev (Vite limitation)
1688+
await expect(page.locator('.test-virtual-style-client-exact')).toHaveCSS(
1689+
'color',
1690+
f.mode === 'dev' ? 'rgb(0, 0, 0)' : 'rgb(200, 50, 100)',
1691+
)
1692+
})
16231693
}

packages/plugin-rsc/examples/basic/src/routes/root.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { TestTreeShakeServer } from './tree-shake/server'
4848
import { TestTreeShake2 } from './tree-shake2/server'
4949
import { TestUseCache } from './use-cache/server'
5050
import { TestUseId } from './use-id/server'
51+
import { TestVirtualModule } from './virtual-module/server'
5152

5253
export function Root(props: { url: URL }) {
5354
return (
@@ -70,6 +71,7 @@ export function Root(props: { url: URL }) {
7071
<TestTailwind />
7172
<TestDepCssInServer />
7273
<TestHydrationMismatch url={props.url} />
74+
<TestVirtualModule />
7375
<TestHmrClientDep url={{ search: props.url.search }} />
7476
<TestHmrClientDep2 url={{ search: props.url.search }} />
7577
<TestHmrClientDep3 />
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use client'
2+
3+
// Client CSS is loaded via JS import (HMR injects styles)
4+
// Both patterns work because no ?direct is involved
5+
import 'virtual:test-style-client-query.css'
6+
import 'virtual:test-style-client-exact.css'
7+
8+
export function TestClientWithVirtualCss() {
9+
return (
10+
<>
11+
<div className="test-virtual-style-client-query">
12+
test-virtual-style-client-query
13+
</div>
14+
<div className="test-virtual-style-client-exact">
15+
test-virtual-style-client-exact
16+
</div>
17+
</>
18+
)
19+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// @ts-expect-error virtual module
2+
import { TestVirtualClient } from 'virtual:test-virtual-client'
3+
import { TestClientWithVirtualCss } from './client'
4+
5+
// Server CSS is loaded via <link> tag in RSC
6+
// Query-aware: works in dev (handles ?direct)
7+
import 'virtual:test-style-server-query.css'
8+
// Exact-match: fails in dev (Vite limitation), works in build
9+
import 'virtual:test-style-server-exact.css'
10+
11+
export function TestVirtualModule() {
12+
return (
13+
<div data-testid="test-virtual-module">
14+
<div className="test-virtual-style-server-query">
15+
test-virtual-style-server-query
16+
</div>
17+
<div className="test-virtual-style-server-exact">
18+
test-virtual-style-server-exact
19+
</div>
20+
<TestClientWithVirtualCss />
21+
<TestVirtualClient />
22+
</div>
23+
)
24+
}

packages/plugin-rsc/examples/basic/vite.config.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default defineConfig({
2121
tailwindcss(),
2222
react(),
2323
vitePluginUseCache(),
24+
vitePluginVirtualModuleTest(),
2425
rsc({
2526
entries: {
2627
client: './src/framework/entry.browser.tsx',
@@ -334,3 +335,85 @@ function vitePluginUseCache(): Plugin[] {
334335
},
335336
]
336337
}
338+
339+
function vitePluginVirtualModuleTest(): Plugin[] {
340+
return [
341+
{
342+
name: 'test-virtual-client',
343+
resolveId(source) {
344+
if (source === 'virtual:test-virtual-client') {
345+
return `\0${source}`
346+
}
347+
},
348+
load(id) {
349+
if (id === '\0virtual:test-virtual-client') {
350+
return `
351+
'use client'
352+
353+
import React from 'react'
354+
355+
export function TestVirtualClient() {
356+
const [clicked, setClicked] = React.useState(false)
357+
return React.createElement(
358+
'button',
359+
{
360+
type: 'button',
361+
'data-testid': 'test-virtual-client',
362+
onClick: () => setClicked(true),
363+
},
364+
'test-virtual-client: ' + (clicked ? 'clicked' : 'not-clicked')
365+
)
366+
}
367+
`
368+
}
369+
},
370+
},
371+
// Query-aware virtual CSS: handles ?direct query, works with <link> in dev
372+
{
373+
name: 'test-virtual-css-query-aware',
374+
resolveId(source) {
375+
const clean = source.split('?')[0]
376+
if (
377+
clean === 'virtual:test-style-server-query.css' ||
378+
clean === 'virtual:test-style-client-query.css'
379+
) {
380+
// Preserve query in resolved id for Vite's CSS plugin to see ?direct
381+
const query = source.includes('?')
382+
? source.slice(source.indexOf('?'))
383+
: ''
384+
return `\0${clean}${query}`
385+
}
386+
},
387+
load(id) {
388+
const clean = id.split('?')[0]
389+
if (clean === '\0virtual:test-style-server-query.css') {
390+
return `.test-virtual-style-server-query { color: rgb(50, 100, 150); }`
391+
}
392+
if (clean === '\0virtual:test-style-client-query.css') {
393+
return `.test-virtual-style-client-query { color: rgb(50, 150, 100); }`
394+
}
395+
},
396+
},
397+
// Exact-match virtual CSS: standard pattern, does NOT work with <link> in dev
398+
// (works fine when imported via JS)
399+
{
400+
name: 'test-virtual-css-exact',
401+
resolveId(source) {
402+
if (source === 'virtual:test-style-server-exact.css') {
403+
return `\0${source}`
404+
}
405+
if (source === 'virtual:test-style-client-exact.css') {
406+
return `\0${source}`
407+
}
408+
},
409+
load(id) {
410+
if (id === '\0virtual:test-style-server-exact.css') {
411+
return `.test-virtual-style-server-exact { color: rgb(200, 100, 50); }`
412+
}
413+
if (id === '\0virtual:test-style-client-exact.css') {
414+
return `.test-virtual-style-client-exact { color: rgb(200, 50, 100); }`
415+
}
416+
},
417+
},
418+
]
419+
}

packages/plugin-rsc/src/plugin.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import { crawlFrameworkPkgs } from 'vitefu'
2727
import vitePluginRscCore from './core/plugin'
2828
import { cjsModuleRunnerPlugin } from './plugins/cjs'
2929
import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url'
30+
import {
31+
vitePluginResolvedIdProxy,
32+
withResolvedIdProxy,
33+
} from './plugins/resolved-id-proxy'
3034
import { scanBuildStripPlugin } from './plugins/scan'
3135
import {
3236
parseCssVirtual,
@@ -299,6 +303,7 @@ export function vitePluginRscMinimal(
299303
},
300304
},
301305
scanBuildStripPlugin({ manager }),
306+
vitePluginResolvedIdProxy(),
302307
]
303308
}
304309

@@ -1357,7 +1362,7 @@ function vitePluginUseClient(
13571362
if (manager.isScanBuild) {
13581363
let code = ``
13591364
for (const meta of Object.values(manager.clientReferenceMetaMap)) {
1360-
code += `import ${JSON.stringify(meta.importId)};\n`
1365+
code += `import ${JSON.stringify(withResolvedIdProxy(meta.importId))};\n`
13611366
}
13621367
return { code, map: null }
13631368
}
@@ -1411,7 +1416,7 @@ function vitePluginUseClient(
14111416
.sort()
14121417
.join('')
14131418
code += `
1414-
import * as import_${meta.referenceKey} from ${JSON.stringify(meta.importId)};
1419+
import * as import_${meta.referenceKey} from ${JSON.stringify(withResolvedIdProxy(meta.importId))};
14151420
export const export_${meta.referenceKey} = {${exports}};
14161421
`
14171422
}

0 commit comments

Comments
 (0)