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 + } + }, + }, + } +}