Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/plugin-rsc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ You can create a starter project by:
npm create vite@latest -- --template rsc
```

### Bundled development

Vite's experimental `bundledDev` mode supports initial RSC rendering and client
HMR. Changes to the server graph, including client-boundary changes, require a
dev-server restart. Vite does not yet expose an atomic bundledDev
rebuild-and-reload operation that plugins can safely request from `watchChange`.

## Examples

**Start here:** [`./examples/starter`](./examples/starter) - Recommended for understanding the package
Expand Down
72 changes: 72 additions & 0 deletions packages/plugin-rsc/e2e/bundled-dev.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test'
import { setupInlineFixture, useFixture } from './fixture'
import { expectNoPageError, expectNoReload, waitForHydration } from './helper'

test.describe('bundled dev', () => {
const root = 'examples/e2e/temp/bundled-dev'

test.beforeAll(async () => {
await setupInlineFixture({
src: 'examples/starter',
dest: root,
files: {
'vite.config.ts': {
edit: (source) =>
source.replace(
'export default defineConfig({',
'export default defineConfig({\n experimental: { bundledDev: true },',
),
},
},
})
})

const fixture = useFixture({ root, mode: 'dev' })

test('serves the initial bundled RSC app', async ({ page }) => {
using _ = expectNoPageError(page)
const requests: string[] = []
page.on('request', (request) => requests.push(request.url()))

await page.goto(fixture.url())
await waitForHydration(page)
await page.getByRole('button', { name: 'Client Counter: 0' }).click()

await expect(
page.getByRole('button', { name: 'Client Counter: 1' }),
).toBeVisible()
await expect(page.locator('.card').first()).toHaveCSS(
'padding-left',
'16px',
)
await expect(page.getByAltText('React logo')).not.toHaveJSProperty(
'naturalWidth',
0,
)
expect(requests).toContain(fixture.url('assets/index.js'))
expect(requests).not.toContain(
fixture.url('src/framework/entry.browser.tsx'),
)
})

test('keeps client HMR', async ({ page }) => {
using _errors = expectNoPageError(page)
await page.goto(fixture.url())
await waitForHydration(page)
await page.getByRole('button', { name: 'Client Counter: 0' }).click()
await using _ = await expectNoReload(page)

const editor = fixture.createEditor('src/client.tsx')
editor.edit((source) =>
source.replace('Client Counter', 'Client [edit] Counter'),
)
await expect(
page.getByRole('button', { name: 'Client [edit] Counter: 1' }),
).toBeVisible()

editor.reset()
await expect(
page.getByRole('button', { name: 'Client Counter: 1' }),
).toBeVisible()
})
})
5 changes: 4 additions & 1 deletion packages/plugin-rsc/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ initialize()
function initialize(): void {
setRequireModule({
load: async (id) => {
if (!import.meta.env.__vite_rsc_build__) {
if (
!import.meta.env.__vite_rsc_build__ &&
!import.meta.env.__vite_rsc_bundled_dev__
) {
// @ts-ignore
return __vite_rsc_raw_import__(
withTrailingSlash(import.meta.env.BASE_URL) + id.slice(1),
Expand Down
128 changes: 128 additions & 0 deletions packages/plugin-rsc/src/bundled-dev.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import fs from 'node:fs'
import path from 'node:path'
import { type DevEnvironment, isCSSRequest, normalizePath } from 'vite'
import { withResolvedIdProxy } from './plugins/resolved-id-proxy'
import { parseIdQuery } from './plugins/shared'
import { cleanUrl } from './plugins/vite-utils'

export type BundledDevServerGraph = {
moduleIds: Set<string>
cssImports: Set<string>
assetImports: Set<string>
}

export async function crawlBundledDevServerGraph(
environment: DevEnvironment,
sources: string[],
): Promise<BundledDevServerGraph> {
const moduleIds = new Set<string>()
const cssImports = new Set<string>()
const assetImports = new Set<string>()
const config = environment.getTopLevelConfig()

async function crawl(source: string): Promise<void> {
const resolved = await environment.pluginContainer.resolveId(source)
if (!resolved || resolved.external || moduleIds.has(resolved.id)) return
moduleIds.add(resolved.id)
if (resolved.id.includes('virtual:vite-rsc/assets-manifest')) return

const { filename, query } = parseIdQuery(resolved.id)
let file = fs.existsSync(filename) ? filename : undefined
if (!file && filename.startsWith('/') && config.publicDir) {
const publicFile = path.join(config.publicDir, filename.slice(1))
if (fs.existsSync(publicFile)) file = publicFile
}
file = file && normalizePath(file)
const hasRawQuery = 'raw' in query
const hasInlineQuery = 'inline' in query
const hasUrlQuery = 'url' in query
const isCss = isCSSRequest(resolved.id)
const isAsset = config.assetsInclude(filename) || hasUrlQuery
const isResource =
isCss || isAsset || hasRawQuery || hasInlineQuery || hasUrlQuery
if (file && isResource) {
if (isCss && !hasRawQuery && !hasInlineQuery && !hasUrlQuery) {
cssImports.add(resolved.id)
} else if (isAsset && !hasRawQuery && !hasInlineQuery) {
assetImports.add(resolved.id)
}
return
}

const requestId =
environment.moduleGraph.getModuleById(resolved.id)?.url ?? resolved.id
const result = await environment.transformRequest(requestId)
await environment.waitForRequestsIdle()
const module = environment.moduleGraph.getModuleById(resolved.id)
if (!module) return

for (const imported of module.importedModules) {
await crawl(imported.url)
}
for (const imported of [
...(result?.deps ?? []),
...(result?.dynamicDeps ?? []),
]) {
await crawl(imported)
}
}

for (const source of sources) {
await crawl(source)
}
return {
moduleIds,
cssImports,
assetImports,
}
}

type ClientReference = {
importId: string
referenceKey: string
}

export function filterBundledDevClientReferences<T extends ClientReference>(
references: Record<string, T>,
moduleIds: Set<string>,
): Record<string, T> {
const reachableIds = new Set(
[...moduleIds].map((id) => normalizePath(cleanUrl(id))),
)
return Object.fromEntries(
Object.entries(references).filter(([id]) =>
reachableIds.has(normalizePath(cleanUrl(id))),
),
)
}

export function renderBundledDevClientReferences(
graph: BundledDevServerGraph,
references: Record<string, ClientReference>,
): string {
const entries: string[] = []
let imports = ''
for (const [index, meta] of Object.values(references)
.sort((a, b) => a.referenceKey.localeCompare(b.referenceKey))
.entries()) {
const name = `__vite_rsc_client_reference_${index}`
imports += `import * as ${name} from ${JSON.stringify(withResolvedIdProxy(meta.importId))};\n`
entries.push(
`${JSON.stringify(meta.referenceKey)}: () => Promise.resolve(${name})`,
)
}
for (const id of [...graph.cssImports].sort()) {
imports += `import ${JSON.stringify(withResolvedIdProxy(id))};\n`
}
const assets = [...graph.assetImports].sort().map((id, index) => {
const name = `__vite_rsc_server_asset_${index}`
imports += `import ${name} from ${JSON.stringify(withResolvedIdProxy(id))};\n`
return name
})
if (assets.length > 0) {
entries.push(
`[Symbol.for("vite-rsc:server-assets")]: [${assets.join(', ')}]`,
)
}
return `${imports}export default {\n${entries.join(',\n')}\n};\n`
}
Loading