From 5b40efe4ff87bb6fd26474ea596d47f5ca4dbf9e Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 23 Aug 2025 13:51:51 +0900 Subject: [PATCH 1/7] chore(rsc): custom client chunks example --- .../plugin-rsc/examples/basic/vite.config.ts | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/examples/basic/vite.config.ts b/packages/plugin-rsc/examples/basic/vite.config.ts index f3f5f1999..3cdaa9f8e 100644 --- a/packages/plugin-rsc/examples/basic/vite.config.ts +++ b/packages/plugin-rsc/examples/basic/vite.config.ts @@ -2,9 +2,16 @@ import assert from 'node:assert' import rsc, { transformHoistInlineDirective } from '@vitejs/plugin-rsc' import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' -import { type Plugin, defineConfig, normalizePath, parseAstAsync } from 'vite' +import { + type Plugin, + type Rollup, + defineConfig, + normalizePath, + parseAstAsync, +} from 'vite' // import inspect from 'vite-plugin-inspect' import path from 'node:path' +import { fileURLToPath } from 'node:url' export default defineConfig({ clearScreen: false, @@ -88,6 +95,76 @@ export default defineConfig({ } }, }, + { + name: 'optimize-chunks', + apply: 'build', + config() { + const resolvePackageSource = (source: string) => + normalizePath(fileURLToPath(import.meta.resolve(source))) + + // TODO: this entrypoint shouldn't be a public API. + const pkgBrowserPath = resolvePackageSource( + '@vitejs/plugin-rsc/react/browser', + ) + + return { + environments: { + client: { + build: { + rollupOptions: { + output: { + manualChunks: (id) => { + // need to use functional form to handle commonjs plugin proxy module + // e.g. `(id)?commonjs-es-import` + if ( + id.includes('node_modules/react/') || + id.includes('node_modules/react-dom/') || + id.includes(pkgBrowserPath) + ) { + return 'lib-react' + } + if (id === '\0vite/preload-helper.js') { + return 'lib-vite' + } + }, + }, + }, + }, + }, + }, + } + }, + // verify chunks are "stable" + writeBundle(_options, bundle) { + if (this.environment.name === 'client') { + const entryChunks: Rollup.OutputChunk[] = [] + const vendorChunks: Rollup.OutputChunk[] = [] + for (const chunk of Object.values(bundle)) { + if (chunk.type === 'chunk') { + if (chunk.facadeModuleId?.endsWith('/src/client.tsx')) { + entryChunks.push(chunk) + } else if (chunk.name === 'lib-react') { + vendorChunks.push(chunk) + } + } + } + + // react vendor chunk has no import + assert.equal(vendorChunks.length, 1) + assert.deepEqual( + vendorChunks[0].imports.filter( + (f) => !f.includes('rolldown-runtime'), + ), + [], + ) + assert.deepEqual(vendorChunks[0].dynamicImports, []) + + // entry chunk has no export + assert.equal(entryChunks.length, 1) + assert.deepEqual(entryChunks[0].exports, []) + } + }, + }, { name: 'cf-build', enforce: 'post', From 7ab9b6f1e8d7189510f54e494a906ad3268f386c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 23 Aug 2025 14:43:57 +0900 Subject: [PATCH 2/7] test: update --- .../plugin-rsc/examples/basic/vite.config.ts | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/plugin-rsc/examples/basic/vite.config.ts b/packages/plugin-rsc/examples/basic/vite.config.ts index 3cdaa9f8e..1bc56cb0b 100644 --- a/packages/plugin-rsc/examples/basic/vite.config.ts +++ b/packages/plugin-rsc/examples/basic/vite.config.ts @@ -103,9 +103,12 @@ export default defineConfig({ normalizePath(fileURLToPath(import.meta.resolve(source))) // TODO: this entrypoint shouldn't be a public API. - const pkgBrowserPath = resolvePackageSource( - '@vitejs/plugin-rsc/react/browser', + const reactServerDom = path.dirname( + resolvePackageSource( + '@vitejs/plugin-rsc/vendor/react-server-dom/client.browser', + ), ) + const rsc = path.dirname(resolvePackageSource('@vitejs/plugin-rsc')) return { environments: { @@ -119,10 +122,13 @@ export default defineConfig({ if ( id.includes('node_modules/react/') || id.includes('node_modules/react-dom/') || - id.includes(pkgBrowserPath) + id.includes(reactServerDom) ) { return 'lib-react' } + if (id.includes(rsc)) { + return 'lib-rsc' + } if (id === '\0vite/preload-helper.js') { return 'lib-vite' } @@ -138,26 +144,27 @@ export default defineConfig({ writeBundle(_options, bundle) { if (this.environment.name === 'client') { const entryChunks: Rollup.OutputChunk[] = [] - const vendorChunks: Rollup.OutputChunk[] = [] + const libChunks: Record = {} for (const chunk of Object.values(bundle)) { if (chunk.type === 'chunk') { - if (chunk.facadeModuleId?.endsWith('/src/client.tsx')) { + if (chunk.isEntry) { entryChunks.push(chunk) - } else if (chunk.name === 'lib-react') { - vendorChunks.push(chunk) + } + if (chunk.name.startsWith('lib-')) { + ;(libChunks[chunk.name] ??= []).push(chunk) } } } // react vendor chunk has no import - assert.equal(vendorChunks.length, 1) + assert.equal(libChunks['lib-react'].length, 1) assert.deepEqual( - vendorChunks[0].imports.filter( + libChunks['lib-react'][0].imports.filter( (f) => !f.includes('rolldown-runtime'), ), [], ) - assert.deepEqual(vendorChunks[0].dynamicImports, []) + assert.deepEqual(libChunks['lib-react'][0].dynamicImports, []) // entry chunk has no export assert.equal(entryChunks.length, 1) From ab70029c4ba728c41e3f9fff134ff2d3ef2d5fe4 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 23 Aug 2025 14:53:54 +0900 Subject: [PATCH 3/7] chore: tweak --- packages/plugin-rsc/examples/basic/vite.config.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/plugin-rsc/examples/basic/vite.config.ts b/packages/plugin-rsc/examples/basic/vite.config.ts index 1bc56cb0b..ff18ba81a 100644 --- a/packages/plugin-rsc/examples/basic/vite.config.ts +++ b/packages/plugin-rsc/examples/basic/vite.config.ts @@ -102,13 +102,10 @@ export default defineConfig({ const resolvePackageSource = (source: string) => normalizePath(fileURLToPath(import.meta.resolve(source))) - // TODO: this entrypoint shouldn't be a public API. - const reactServerDom = path.dirname( - resolvePackageSource( - '@vitejs/plugin-rsc/vendor/react-server-dom/client.browser', - ), + // TODO: this package entry isn't a public API. + const reactServerDom = resolvePackageSource( + '@vitejs/plugin-rsc/react/browser', ) - const rsc = path.dirname(resolvePackageSource('@vitejs/plugin-rsc')) return { environments: { @@ -126,9 +123,6 @@ export default defineConfig({ ) { return 'lib-react' } - if (id.includes(rsc)) { - return 'lib-rsc' - } if (id === '\0vite/preload-helper.js') { return 'lib-vite' } From a90e18261689668faa8b44167c987d0b11b1ea2a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 23 Aug 2025 15:26:51 +0900 Subject: [PATCH 4/7] chore: comment --- packages/plugin-rsc/examples/basic/vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-rsc/examples/basic/vite.config.ts b/packages/plugin-rsc/examples/basic/vite.config.ts index ff18ba81a..319e62717 100644 --- a/packages/plugin-rsc/examples/basic/vite.config.ts +++ b/packages/plugin-rsc/examples/basic/vite.config.ts @@ -153,6 +153,7 @@ export default defineConfig({ // react vendor chunk has no import assert.equal(libChunks['lib-react'].length, 1) assert.deepEqual( + // https://rolldown.rs/guide/in-depth/advanced-chunks#why-there-s-always-a-runtime-js-chunk libChunks['lib-react'][0].imports.filter( (f) => !f.includes('rolldown-runtime'), ), From 1de3cb82db710fa204f09100348a0b58e26608dc Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 23 Aug 2025 15:42:33 +0900 Subject: [PATCH 5/7] test: wip --- packages/plugin-rsc/e2e/basic.test.ts | 44 ++++++++++++++++++++++++++- packages/plugin-rsc/e2e/fixture.ts | 33 ++++++++++++-------- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index 7b4a528e8..04022e5d3 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -1,13 +1,16 @@ import { createHash } from 'node:crypto' import { readFileSync } from 'node:fs' import { type Page, expect, test } from '@playwright/test' -import { type Fixture, useFixture } from './fixture' +import { type Fixture, useCreateEditor, useFixture } from './fixture' import { expectNoPageError, expectNoReload, testNoJs, waitForHydration, } from './helper' +import { x } from 'tinyexec' +import fs from 'node:fs' +import path from 'node:path' test.describe('dev-default', () => { const f = useFixture({ root: 'examples/basic', mode: 'dev' }) @@ -98,6 +101,45 @@ test.describe('dev-inconsistent-client-optimization', () => { }) }) +test.describe('build-stable-chunks', () => { + const root = 'examples/basic' + const createEditor = useCreateEditor(root) + + test('basic', async () => { + // 1st build + await x('pnpm', ['build'], { + throwOnError: true, + nodeOptions: { + cwd: root, + }, + }) + const manifest1 = JSON.parse( + createEditor('dist/client/.vite/manifest.json').read(), + ) + + // edit src/routes/client.tsx + const editor = createEditor('src/routes/client.tsx') + editor.edit((s) => s.replace('client-counter', 'client-counter-v2')) + + // 2nd build + await x('pnpm', ['build'], { + throwOnError: true, + nodeOptions: { + cwd: root, + }, + }) + const manifest2 = JSON.parse( + createEditor('dist/client/.vite/manifest.json').read(), + ) + + // compare two mainfest.json + console.log({ + manifest1, + manifest2, + }) + }) +}) + function defineTest(f: Fixture) { test('basic', async ({ page }) => { using _ = expectNoPageError(page) diff --git a/packages/plugin-rsc/e2e/fixture.ts b/packages/plugin-rsc/e2e/fixture.ts index 6faff1b0b..a34d298da 100644 --- a/packages/plugin-rsc/e2e/fixture.ts +++ b/packages/plugin-rsc/e2e/fixture.ts @@ -125,14 +125,33 @@ export function useFixture(options: { await cleanup?.() }) + const createEditor = useCreateEditor(cwd) + + return { + mode: options.mode, + root: cwd, + url: (url: string = './') => new URL(url, baseURL).href, + createEditor, + proc: () => proc, + } +} + +export function useCreateEditor(cwd: string) { const originalFiles: Record = {} + test.afterAll(async () => { + for (const [filepath, content] of Object.entries(originalFiles)) { + fs.writeFileSync(filepath, content) + } + }) + function createEditor(filepath: string) { filepath = path.resolve(cwd, filepath) const init = fs.readFileSync(filepath, 'utf-8') originalFiles[filepath] ??= init let current = init return { + read: () => current, edit(editFn: (data: string) => string): void { const next = editFn(current) assert(next !== current, 'Edit function did not change the content') @@ -148,19 +167,7 @@ export function useFixture(options: { } } - test.afterAll(async () => { - for (const [filepath, content] of Object.entries(originalFiles)) { - fs.writeFileSync(filepath, content) - } - }) - - return { - mode: options.mode, - root: cwd, - url: (url: string = './') => new URL(url, baseURL).href, - createEditor, - proc: () => proc, - } + return createEditor } export async function setupIsolatedFixture(options: { From c00b62d996232af99b4ff5fe0d017f6a38bc843f Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 23 Aug 2025 15:58:16 +0900 Subject: [PATCH 6/7] test: e2e --- packages/plugin-rsc/e2e/basic.test.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index 04022e5d3..b396adc2f 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -105,7 +105,7 @@ test.describe('build-stable-chunks', () => { const root = 'examples/basic' const createEditor = useCreateEditor(root) - test('basic', async () => { + test.only('basic', async () => { // 1st build await x('pnpm', ['build'], { throwOnError: true, @@ -113,7 +113,7 @@ test.describe('build-stable-chunks', () => { cwd: root, }, }) - const manifest1 = JSON.parse( + const manifest1: import('vite').Manifest = JSON.parse( createEditor('dist/client/.vite/manifest.json').read(), ) @@ -128,15 +128,26 @@ test.describe('build-stable-chunks', () => { cwd: root, }, }) - const manifest2 = JSON.parse( + const manifest2: import('vite').Manifest = JSON.parse( createEditor('dist/client/.vite/manifest.json').read(), ) // compare two mainfest.json - console.log({ - manifest1, - manifest2, - }) + const files1 = new Set(Object.values(manifest1).map((v) => v.file)) + const files2 = new Set(Object.values(manifest2).map((v) => v.file)) + const oldChunks = Object.entries(manifest2) + .filter(([_k, v]) => !files1.has(v.file)) + .map(([k]) => k) + .sort() + const newChunks = Object.entries(manifest1) + .filter(([_k, v]) => !files2.has(v.file)) + .map(([k]) => k) + .sort() + expect(newChunks).toEqual([ + 'src/framework/entry.browser.tsx', + 'src/routes/client.tsx', + ]) + expect(oldChunks).toEqual(newChunks) }) }) From b8f9c26dbf52d11c4859ca1a95dc0acbcbf5f830 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 23 Aug 2025 16:00:51 +0900 Subject: [PATCH 7/7] chore: cleanup --- packages/plugin-rsc/e2e/basic.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index b396adc2f..e14111da8 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -9,8 +9,6 @@ import { waitForHydration, } from './helper' import { x } from 'tinyexec' -import fs from 'node:fs' -import path from 'node:path' test.describe('dev-default', () => { const f = useFixture({ root: 'examples/basic', mode: 'dev' }) @@ -105,7 +103,7 @@ test.describe('build-stable-chunks', () => { const root = 'examples/basic' const createEditor = useCreateEditor(root) - test.only('basic', async () => { + test('basic', async () => { // 1st build await x('pnpm', ['build'], { throwOnError: true,