diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts
index 911127641..90b17529e 100644
--- a/packages/plugin-rsc/e2e/basic.test.ts
+++ b/packages/plugin-rsc/e2e/basic.test.ts
@@ -1620,4 +1620,74 @@ function defineTest(f: Fixture) {
'test-tree-shake2:lib-client1|lib-server1',
)
})
+
+ test('virtual module with use client', async ({ page }) => {
+ await page.goto(f.url())
+ await waitForHydration(page)
+
+ // Test that the virtual client component renders and works
+ await expect(page.getByTestId('test-virtual-client')).toHaveText(
+ 'test-virtual-client: not-clicked',
+ )
+ await page.getByTestId('test-virtual-client').click()
+ await expect(page.getByTestId('test-virtual-client')).toHaveText(
+ 'test-virtual-client: clicked',
+ )
+ })
+
+ test('virtual css module', async ({ page }) => {
+ await page.goto(f.url())
+ await waitForHydration(page)
+
+ // Server CSS (loaded via )
+ // Query-aware: works in both dev and build
+ await expect(page.locator('.test-virtual-style-server-query')).toHaveCSS(
+ 'color',
+ 'rgb(50, 100, 150)',
+ )
+ // Exact-match: fails via in dev (Vite limitation), works in build
+ await expect(page.locator('.test-virtual-style-server-exact')).toHaveCSS(
+ 'color',
+ f.mode === 'dev' ? 'rgb(0, 0, 0)' : 'rgb(200, 100, 50)',
+ )
+
+ // Client CSS (loaded via JS import, HMR injects styles)
+ // Both patterns work because no ?direct is involved in JS imports
+ await expect(page.locator('.test-virtual-style-client-query')).toHaveCSS(
+ 'color',
+ 'rgb(50, 150, 100)',
+ )
+ await expect(page.locator('.test-virtual-style-client-exact')).toHaveCSS(
+ 'color',
+ 'rgb(200, 50, 100)',
+ )
+ })
+
+ testNoJs('virtual css module @nojs', async ({ page }) => {
+ await page.goto(f.url())
+
+ // Server CSS (loaded via )
+ // Query-aware: works in both dev and build
+ await expect(page.locator('.test-virtual-style-server-query')).toHaveCSS(
+ 'color',
+ 'rgb(50, 100, 150)',
+ )
+ // Exact-match: fails via in dev (Vite limitation)
+ await expect(page.locator('.test-virtual-style-server-exact')).toHaveCSS(
+ 'color',
+ f.mode === 'dev' ? 'rgb(0, 0, 0)' : 'rgb(200, 100, 50)',
+ )
+
+ // Client CSS (loaded via in noJS mode)
+ // Query-aware: works in both dev and build
+ await expect(page.locator('.test-virtual-style-client-query')).toHaveCSS(
+ 'color',
+ 'rgb(50, 150, 100)',
+ )
+ // Exact-match: fails via in dev (Vite limitation)
+ await expect(page.locator('.test-virtual-style-client-exact')).toHaveCSS(
+ 'color',
+ f.mode === 'dev' ? 'rgb(0, 0, 0)' : 'rgb(200, 50, 100)',
+ )
+ })
}
diff --git a/packages/plugin-rsc/examples/basic/src/routes/root.tsx b/packages/plugin-rsc/examples/basic/src/routes/root.tsx
index 715c5d5d8..c11d9661a 100644
--- a/packages/plugin-rsc/examples/basic/src/routes/root.tsx
+++ b/packages/plugin-rsc/examples/basic/src/routes/root.tsx
@@ -48,6 +48,7 @@ import { TestTreeShakeServer } from './tree-shake/server'
import { TestTreeShake2 } from './tree-shake2/server'
import { TestUseCache } from './use-cache/server'
import { TestUseId } from './use-id/server'
+import { TestVirtualModule } from './virtual-module/server'
export function Root(props: { url: URL }) {
return (
@@ -70,6 +71,7 @@ export function Root(props: { url: URL }) {
+
diff --git a/packages/plugin-rsc/examples/basic/src/routes/virtual-module/client.tsx b/packages/plugin-rsc/examples/basic/src/routes/virtual-module/client.tsx
new file mode 100644
index 000000000..269b2bf80
--- /dev/null
+++ b/packages/plugin-rsc/examples/basic/src/routes/virtual-module/client.tsx
@@ -0,0 +1,19 @@
+'use client'
+
+// Client CSS is loaded via JS import (HMR injects styles)
+// Both patterns work because no ?direct is involved
+import 'virtual:test-style-client-query.css'
+import 'virtual:test-style-client-exact.css'
+
+export function TestClientWithVirtualCss() {
+ return (
+ <>
+
+ test-virtual-style-client-query
+
+
+ test-virtual-style-client-exact
+
+ >
+ )
+}
diff --git a/packages/plugin-rsc/examples/basic/src/routes/virtual-module/server.tsx b/packages/plugin-rsc/examples/basic/src/routes/virtual-module/server.tsx
new file mode 100644
index 000000000..0543e432e
--- /dev/null
+++ b/packages/plugin-rsc/examples/basic/src/routes/virtual-module/server.tsx
@@ -0,0 +1,24 @@
+// @ts-expect-error virtual module
+import { TestVirtualClient } from 'virtual:test-virtual-client'
+import { TestClientWithVirtualCss } from './client'
+
+// Server CSS is loaded via tag in RSC
+// Query-aware: works in dev (handles ?direct)
+import 'virtual:test-style-server-query.css'
+// Exact-match: fails in dev (Vite limitation), works in build
+import 'virtual:test-style-server-exact.css'
+
+export function TestVirtualModule() {
+ return (
+
+
+ test-virtual-style-server-query
+
+
+ test-virtual-style-server-exact
+
+
+
+
+ )
+}
diff --git a/packages/plugin-rsc/examples/basic/vite.config.ts b/packages/plugin-rsc/examples/basic/vite.config.ts
index eafc96002..0106415d7 100644
--- a/packages/plugin-rsc/examples/basic/vite.config.ts
+++ b/packages/plugin-rsc/examples/basic/vite.config.ts
@@ -21,6 +21,7 @@ export default defineConfig({
tailwindcss(),
react(),
vitePluginUseCache(),
+ vitePluginVirtualModuleTest(),
rsc({
entries: {
client: './src/framework/entry.browser.tsx',
@@ -334,3 +335,85 @@ function vitePluginUseCache(): Plugin[] {
},
]
}
+
+function vitePluginVirtualModuleTest(): Plugin[] {
+ return [
+ {
+ name: 'test-virtual-client',
+ resolveId(source) {
+ if (source === 'virtual:test-virtual-client') {
+ return `\0${source}`
+ }
+ },
+ load(id) {
+ if (id === '\0virtual:test-virtual-client') {
+ return `
+'use client'
+
+import React from 'react'
+
+export function TestVirtualClient() {
+ const [clicked, setClicked] = React.useState(false)
+ return React.createElement(
+ 'button',
+ {
+ type: 'button',
+ 'data-testid': 'test-virtual-client',
+ onClick: () => setClicked(true),
+ },
+ 'test-virtual-client: ' + (clicked ? 'clicked' : 'not-clicked')
+ )
+}
+`
+ }
+ },
+ },
+ // Query-aware virtual CSS: handles ?direct query, works with in dev
+ {
+ name: 'test-virtual-css-query-aware',
+ resolveId(source) {
+ const clean = source.split('?')[0]
+ if (
+ clean === 'virtual:test-style-server-query.css' ||
+ clean === 'virtual:test-style-client-query.css'
+ ) {
+ // Preserve query in resolved id for Vite's CSS plugin to see ?direct
+ const query = source.includes('?')
+ ? source.slice(source.indexOf('?'))
+ : ''
+ return `\0${clean}${query}`
+ }
+ },
+ load(id) {
+ const clean = id.split('?')[0]
+ if (clean === '\0virtual:test-style-server-query.css') {
+ return `.test-virtual-style-server-query { color: rgb(50, 100, 150); }`
+ }
+ if (clean === '\0virtual:test-style-client-query.css') {
+ return `.test-virtual-style-client-query { color: rgb(50, 150, 100); }`
+ }
+ },
+ },
+ // Exact-match virtual CSS: standard pattern, does NOT work with in dev
+ // (works fine when imported via JS)
+ {
+ name: 'test-virtual-css-exact',
+ resolveId(source) {
+ if (source === 'virtual:test-style-server-exact.css') {
+ return `\0${source}`
+ }
+ if (source === 'virtual:test-style-client-exact.css') {
+ return `\0${source}`
+ }
+ },
+ load(id) {
+ if (id === '\0virtual:test-style-server-exact.css') {
+ return `.test-virtual-style-server-exact { color: rgb(200, 100, 50); }`
+ }
+ if (id === '\0virtual:test-style-client-exact.css') {
+ return `.test-virtual-style-client-exact { color: rgb(200, 50, 100); }`
+ }
+ },
+ },
+ ]
+}
diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts
index bf7202e98..413c9866f 100644
--- a/packages/plugin-rsc/src/plugin.ts
+++ b/packages/plugin-rsc/src/plugin.ts
@@ -27,6 +27,10 @@ import { crawlFrameworkPkgs } from 'vitefu'
import vitePluginRscCore from './core/plugin'
import { cjsModuleRunnerPlugin } from './plugins/cjs'
import { vitePluginFindSourceMapURL } from './plugins/find-source-map-url'
+import {
+ vitePluginResolvedIdProxy,
+ withResolvedIdProxy,
+} from './plugins/resolved-id-proxy'
import { scanBuildStripPlugin } from './plugins/scan'
import {
parseCssVirtual,
@@ -299,6 +303,7 @@ export function vitePluginRscMinimal(
},
},
scanBuildStripPlugin({ manager }),
+ vitePluginResolvedIdProxy(),
]
}
@@ -1357,7 +1362,7 @@ function vitePluginUseClient(
if (manager.isScanBuild) {
let code = ``
for (const meta of Object.values(manager.clientReferenceMetaMap)) {
- code += `import ${JSON.stringify(meta.importId)};\n`
+ code += `import ${JSON.stringify(withResolvedIdProxy(meta.importId))};\n`
}
return { code, map: null }
}
@@ -1411,7 +1416,7 @@ function vitePluginUseClient(
.sort()
.join('')
code += `
- import * as import_${meta.referenceKey} from ${JSON.stringify(meta.importId)};
+ import * as import_${meta.referenceKey} from ${JSON.stringify(withResolvedIdProxy(meta.importId))};
export const export_${meta.referenceKey} = {${exports}};
`
}
diff --git a/packages/plugin-rsc/src/plugins/resolved-id-proxy.ts b/packages/plugin-rsc/src/plugins/resolved-id-proxy.ts
new file mode 100644
index 000000000..4f2e8b67d
--- /dev/null
+++ b/packages/plugin-rsc/src/plugins/resolved-id-proxy.ts
@@ -0,0 +1,142 @@
+import type { Plugin } from 'vite'
+
+// Resolved ID proxy plugin
+// This enables virtual modules (with \0 prefix) to be used in import specifiers.
+//
+// Input/Output examples:
+//
+// toResolvedIdProxy("\0virtual:test.css")
+// => "virtual:vite-rsc/resolved-id/__x00__virtual:test.css"
+//
+// fromResolvedIdProxy("virtual:vite-rsc/resolved-id/__x00__virtual:test.css")
+// => "\0virtual:test.css"
+//
+// fromResolvedIdProxy("virtual:vite-rsc/resolved-id/__x00__virtual:test.css?direct")
+// => "\0virtual:test.css"
+//
+/*
+Known Vite limitation: Virtual CSS modules don't work with ?direct or ?inline queries.
+
+## Standard virtual module pattern
+
+Vite/Rollup virtual modules use a \0 prefix to indicate resolved IDs that
+don't correspond to real files. Example from examples/basic/vite.config.ts:
+
+ resolveId(source) {
+ if (source === 'virtual:test-style-server.css') {
+ return `\0${source}` // → \0virtual:test-style-server.css
+ }
+ },
+ load(id) {
+ if (id === '\0virtual:test-style-server.css') {
+ return `.test { color: red; }`
+ }
+ }
+
+Browsers can't use \0 (null byte) in URLs, so Vite encodes it as __x00__:
+
+
+Vite's dev server maps /@id/__x00__... back to \0... internally.
+
+## How Vite's ?direct CSS mechanism works
+
+When a browser requests CSS via , Vite's dev server
+middleware (transform.ts) detects the Accept: text/css header and injects
+a ?direct query parameter:
+
+ Browser:
+ ↓
+ Middleware: xxx.css -> xxx.css?direct (based on Accept: text/css header)
+ ↓
+ transformRequest(url) where url = "xxx.css?direct"
+ ↓
+ Plugin Pipeline: resolveId → load → transform
+
+In the CSS plugin's transform hook (css.ts:577-579), the ?direct query
+signals that raw CSS should be returned instead of a JS wrapper:
+
+ transform(code, id) {
+ if (isDirectCSSRequest(id)) {
+ return null // Let CSS pass through unchanged
+ }
+ // Otherwise, wrap CSS in JS with HMR code
+ }
+
+## Why virtual modules break with ?direct (and ?inline)
+
+Standard virtual module plugins use exact string matching:
+
+ resolveId(source) {
+ if (source === 'virtual:test-style-server.css') { // exact match
+ return `\0${source}`
+ }
+ }
+
+When Vite middleware adds ?direct (or user imports with ?inline), the source
+becomes 'virtual:test-style-server.css?direct' which doesn't match.
+
+For virtual CSS modules, the flow becomes:
+
+ 1. Browser:
+ 2. Vite maps /@id/__x00__... → \0...
+ 3. Middleware: injects ?direct → "\0virtual:test-style-server.css?direct"
+ 4. resolveId("\0virtual:test-style-server.css?direct") → no match, unresolved!
+ 5. Load fails or returns wrong content
+
+## Why regular CSS files work
+
+For actual files like /src/style.css?direct, Vite/Rolldown has a fallback:
+when resolveId/load fails with query params, it retries with query stripped.
+So /src/style.css?direct eventually resolves to the file /src/style.css.
+
+Virtual modules don't benefit from this fallback because they rely on
+exact string matching in user plugins, not filesystem resolution.
+
+## Conclusion
+
+This is a Vite limitation. The fix would require Vite to either:
+1. Provide a query-aware virtual module resolution helper, OR
+2. Apply the query-stripping fallback to virtual modules too
+
+Workaround: User plugins can manually handle queries by stripping them in
+resolveId and load hooks. See examples/basic/vite.config.ts for patterns.
+*/
+
+const RESOLVED_ID_PROXY_PREFIX = 'virtual:vite-rsc/resolved-id/'
+
+export function toResolvedIdProxy(resolvedId: string): string {
+ return RESOLVED_ID_PROXY_PREFIX + encodeURIComponent(resolvedId)
+}
+
+export function withResolvedIdProxy(resolvedId: string): string {
+ return resolvedId.startsWith('\0')
+ ? toResolvedIdProxy(resolvedId)
+ : resolvedId
+}
+
+export function fromResolvedIdProxy(source: string): string | undefined {
+ if (!source.startsWith(RESOLVED_ID_PROXY_PREFIX)) {
+ return undefined
+ }
+ // Strip query params (e.g., ?direct added by Vite for CSS)
+ const clean = source.split('?')[0]!
+ return decodeURIComponent(clean.slice(RESOLVED_ID_PROXY_PREFIX.length))
+}
+
+/**
+ * Vite plugin that resolves proxy import specifiers to the original resolved IDs.
+ */
+export function vitePluginResolvedIdProxy(): Plugin {
+ return {
+ name: 'rsc:resolved-id-proxy',
+ resolveId: {
+ // TODO: filter
+ handler(source) {
+ const originalId = fromResolvedIdProxy(source)
+ if (originalId !== undefined) {
+ return originalId
+ }
+ },
+ },
+ }
+}