diff --git a/packages/react/src/plugin-pages.ts b/packages/react/src/plugin-pages.ts index 1c847f02f..1020f3a74 100644 --- a/packages/react/src/plugin-pages.ts +++ b/packages/react/src/plugin-pages.ts @@ -1,7 +1,13 @@ -import { dirname, relative } from 'pathslash'; +import { readFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'pathslash'; import type { Plugin } from 'vite'; -import { createPagesPlugin, generateComponentManifest } from 'void/pages'; +import { + createPagesPlugin, + generateComponentManifest, + resolveEffectiveLayoutChain, +} from 'void/pages'; import type { PageScanResult, StaticPageDataBuildOptions } from 'void/pages'; +import { findIslandImports, resolveSpecifier } from 'void/pages-islands-plugin'; const CLIENT_ENTRY_ID = 'virtual:void-pages-client'; const WORKER_ENVIRONMENT_NAME = 'void_worker'; @@ -62,10 +68,15 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu }, optimizeDeps: { exclude: ['@void/react'], + // Crawl page modules before the first client request so third-party + // imports are pre-bundled instead of triggering a full reload. + entries: clientOptimizeEntries(await scan(), pagesDir, root), // Pre-include React entries that are discovered late via dynamic // imports during SPA navigation, avoiding a full-page reload - // from Vite's dep optimizer. - include: CLIENT_OPTIMIZE_DEPS, + // from Vite's dep optimizer. void/pages-client and + // void/pages-prefetch are reached only through virtual entries and + // the excluded @void/react, so the scanner cannot see them. + include: [...CLIENT_OPTIMIZE_DEPS, 'void/pages-client', 'void/pages-prefetch'], }, ssr: { optimizeDeps: { @@ -109,6 +120,49 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu }); } +function clientOptimizeEntries( + scan: PageScanResult | null, + pagesDir: string, + root: string, +): Array { + if (!scan) { + return []; + } + const browserFiles = new Set(); + const islandFiles = new Set(); + for (const page of scan.pages) { + const layouts = resolveEffectiveLayoutChain(page, scan.layouts, scan.namedLayouts); + if (page.island) { + islandFiles.add(resolve(pagesDir, page.componentPath)); + for (const layout of layouts) { + if (layout.island) { + islandFiles.add(resolve(pagesDir, layout.filePath)); + } + } + } else { + browserFiles.add(resolve(pagesDir, page.componentPath)); + for (const layout of layouts) { + browserFiles.add(resolve(pagesDir, layout.filePath)); + } + } + } + // Only hydrated components run in the browser; island pages and their + // unmarked imports may depend on Worker-only packages. + for (const file of islandFiles) { + if (!/\.island\.(tsx|jsx)$/.test(file)) { + continue; + } + for (const { specifier } of findIslandImports(readFileSync(file, 'utf8'), file)) { + const component = resolveSpecifier(specifier, dirname(file), ['.tsx', '.jsx', '.ts', '.js']); + if (component) { + browserFiles.add(component); + } + } + } + // Entries are glob patterns, so route names such as [id] must be literal. + return [...browserFiles].map((file) => relative(root, file).replace(/[()[\]{}*?!|]/g, '\\$&')); +} + function generateRendererModule(scan: PageScanResult, pagesDir: string, isDev: boolean): string { const preambleScript = isDev ? REACT_REFRESH_PREAMBLE : ''; diff --git a/packages/react/test/integration/react-pages-client.test.ts b/packages/react/test/integration/react-pages-client.test.ts index ff4cbc3d2..3c8b1e22b 100644 --- a/packages/react/test/integration/react-pages-client.test.ts +++ b/packages/react/test/integration/react-pages-client.test.ts @@ -41,12 +41,31 @@ const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const REACT_PAGES_ROOT = join(ROOT, 'playground/react-pages'); const REACT_PAGES_LAYOUT = join(REACT_PAGES_ROOT, 'pages/layout.tsx'); const LATE_WORKER_DEPS_ROOT = join(REACT_PAGES_ROOT, 'node_modules/@void-repro'); +const OPTIMIZER_PROBE_FILES = { + '_optimizer-helper.ts': `import { value } from '@void-repro/worker-only'; +export const readWorkerValue = () => value;`, + '_optimizer-browser.ts': `export { value } from '@void-repro/browser-only';`, + 'optimizer-probe/[id].tsx': `import { value } from '../_optimizer-browser'; +export default function Probe() { return

{value}

; }`, + 'optimizer-probe/[id].server.ts': `import { defineHandler } from 'void/handler'; +import { readWorkerValue } from '../_optimizer-helper'; +export const loader = defineHandler(() => ({ value: readWorkerValue() }));`, + 'optimizer-island.island.tsx': `import Probe from './_OptimizerIsland' with { island: 'load' }; +import { readWorkerValue } from './_optimizer-helper'; +export default function IslandPage() { return <>

{readWorkerValue()}

; }`, + '_OptimizerIsland.tsx': `import { value } from '@void-repro/island-only'; +export default function Probe() { return

{value}

; }`, +}; let DEV_BASE: string; let PREVIEW_BASE: string; let browser: Browser; -function writeLateWorkerDep(name: string, source: string) { +function writeLateWorkerDep( + name: string, + source: string, + exports: string | Record = './index.js', +) { const packageDir = join(LATE_WORKER_DEPS_ROOT, name); mkdirSync(packageDir, { recursive: true }); writeFileSync( @@ -55,7 +74,7 @@ function writeLateWorkerDep(name: string, source: string) { name: `@void-repro/${name}`, version: '1.0.0', type: 'module', - exports: './index.js', + exports, }), ); writeFileSync(join(packageDir, 'index.js'), source); @@ -63,6 +82,16 @@ function writeLateWorkerDep(name: string, source: string) { function installLateWorkerDeps() { rmSync(LATE_WORKER_DEPS_ROOT, { recursive: true, force: true }); + writeLateWorkerDep('worker-only', 'export const value = "Worker value";', { + workerd: './index.js', + }); + writeLateWorkerDep('browser-only', 'export const value = "Browser value";'); + writeLateWorkerDep('island-only', 'export const value = "Island value";'); + for (const [file, source] of Object.entries(OPTIMIZER_PROBE_FILES)) { + const path = join(REACT_PAGES_ROOT, 'pages', file); + mkdirSync(join(path, '..'), { recursive: true }); + writeFileSync(path, source); + } writeLateWorkerDep( 'boundary', `import React from 'react'; @@ -740,6 +769,25 @@ describe('react-pages client (dev)', () => { await killChild(child); } rmSync(LATE_WORKER_DEPS_ROOT, { recursive: true, force: true }); + for (const file of Object.keys(OPTIMIZER_PROBE_FILES)) { + rmSync(join(REACT_PAGES_ROOT, 'pages', file), { force: true }); + } + rmSync(join(REACT_PAGES_ROOT, 'pages/optimizer-probe'), { recursive: true, force: true }); + }); + + it('dev: prebundles browser roots without scanning server-only dependencies', async () => { + await vi.waitFor(() => { + const metadata = JSON.parse( + readFileSync(join(REACT_PAGES_ROOT, 'node_modules/.vite/deps/_metadata.json'), 'utf8'), + ); + expect(metadata.optimized).toHaveProperty('@void-repro/browser-only'); + expect(metadata.optimized).toHaveProperty('@void-repro/island-only'); + expect(metadata.optimized).not.toHaveProperty('@void-repro/worker-only'); + }); + expect(stderr).not.toContain('Failed to run dependency scan'); + const response = await fetch(`${DEV_BASE}/optimizer-probe/example`); + expect(response.status).toBe(200); + expect(await response.text()).toContain('Browser value'); }); it('dev: cold SSR startup does not hit invalid React hook calls', () => { diff --git a/packages/react/test/unit/plugin-pages.test.ts b/packages/react/test/unit/plugin-pages.test.ts index 365027e65..ef4c5a2ca 100644 --- a/packages/react/test/unit/plugin-pages.test.ts +++ b/packages/react/test/unit/plugin-pages.test.ts @@ -202,10 +202,15 @@ describe('pagesPlugin dev config', () => { rmSync(TMP, { recursive: true, force: true }); }); - it('pre-optimizes React dependencies in the Cloudflare worker environment', async () => { + it('pre-optimizes page dependencies in the client and Cloudflare worker environments', async () => { + writePage('index.tsx', 'export default function Home() { return

Home

; }'); const config = await loadServeConfig(); expect(config).toMatchObject({ + optimizeDeps: { + entries: ['pages/index.tsx'], + include: expect.arrayContaining(['void/pages-client', 'void/pages-prefetch']), + }, environments: { void_worker: { optimizeDeps: { @@ -223,6 +228,65 @@ describe('pagesPlugin dev config', () => { }); }); + it('scans browser roots without promoting server-only helpers to client entries', async () => { + writePage('index.tsx', 'export default function Home() { return null; }'); + writePage('index.server.ts', 'import "./_worker-helper";'); + writePage('_worker-helper.ts', 'import "worker-only-package";'); + writePage('layout.tsx', 'export default function Layout({ children }) { return children; }'); + writePage( + 'users/[id].dev.tsx', + 'export const layout = "profile"; export default function User() { return null; }', + ); + writePage('users/[id].prod.tsx', 'export default function User() { return null; }'); + writePage('_layouts/profile.tsx', 'export default function Profile() { return null; }'); + writePage('_layouts/unused.tsx', 'import "worker-only-package";'); + writePage('static/layout.tsx', 'import "worker-only-package";'); + writePage('static/index.island.tsx', 'export default function Static() { return null; }'); + writePage('__tests__/page.test.tsx', 'import "test-only-package";'); + + const config = await loadServeConfig(); + expect(config).toMatchObject({ + optimizeDeps: { + entries: expect.arrayContaining([ + 'pages/index.tsx', + 'pages/layout.tsx', + 'pages/users/\\[id\\].dev.tsx', + 'pages/_layouts/profile.tsx', + ]), + }, + }); + expect((config.optimizeDeps as { entries: Array }).entries).toHaveLength(4); + }); + + it('scans hydrated components in island pages and layouts, including outside pages', async () => { + writePage( + 'index.island.tsx', + ` + import Counter from './_Counter' with { island: 'load' }; + import './_worker-helper'; + export default function Home() { return ; } + `, + ); + writePage( + 'layout.island.tsx', + ` + import Header from '../components/Header.tsx' with { island: 'visible' }; + export default function Layout({ children }) { return <>
{children}; } + `, + ); + writePage('_Counter.tsx', 'export default function Counter() { return null; }'); + writePage('../components/Header.tsx', 'export default function Header() { return null; }'); + writePage('_worker-helper.ts', 'import "worker-only-package";'); + + const config = await loadServeConfig(); + expect(config).toMatchObject({ + optimizeDeps: { + entries: expect.arrayContaining(['pages/_Counter.tsx', 'components/Header.tsx']), + }, + }); + expect((config.optimizeDeps as { entries: Array }).entries).toHaveLength(2); + }); + it('includes only the React entry points needed by each environment', async () => { const config = (await loadServeConfig()) as { optimizeDeps: { include: Array };