diff --git a/packages/plugin-rsc/e2e/basic.test.ts b/packages/plugin-rsc/e2e/basic.test.ts index 7b4a528e..e14111da 100644 --- a/packages/plugin-rsc/e2e/basic.test.ts +++ b/packages/plugin-rsc/e2e/basic.test.ts @@ -1,13 +1,14 @@ 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' test.describe('dev-default', () => { const f = useFixture({ root: 'examples/basic', mode: 'dev' }) @@ -98,6 +99,56 @@ 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: import('vite').Manifest = 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: import('vite').Manifest = JSON.parse( + createEditor('dist/client/.vite/manifest.json').read(), + ) + + // compare two mainfest.json + 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) + }) +}) + 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 6faff1b0..a34d298d 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: { diff --git a/packages/plugin-rsc/examples/basic/vite.config.ts b/packages/plugin-rsc/examples/basic/vite.config.ts index f3f5f199..319e6271 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,78 @@ export default defineConfig({ } }, }, + { + name: 'optimize-chunks', + apply: 'build', + config() { + const resolvePackageSource = (source: string) => + normalizePath(fileURLToPath(import.meta.resolve(source))) + + // TODO: this package entry isn't a public API. + const reactServerDom = 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(reactServerDom) + ) { + 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 libChunks: Record = {} + for (const chunk of Object.values(bundle)) { + if (chunk.type === 'chunk') { + if (chunk.isEntry) { + entryChunks.push(chunk) + } + if (chunk.name.startsWith('lib-')) { + ;(libChunks[chunk.name] ??= []).push(chunk) + } + } + } + + // 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'), + ), + [], + ) + assert.deepEqual(libChunks['lib-react'][0].dynamicImports, []) + + // entry chunk has no export + assert.equal(entryChunks.length, 1) + assert.deepEqual(entryChunks[0].exports, []) + } + }, + }, { name: 'cf-build', enforce: 'post',