Skip to content
Merged
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
62 changes: 58 additions & 4 deletions packages/react/src/plugin-pages.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -109,6 +120,49 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu
});
}

function clientOptimizeEntries(
scan: PageScanResult | null,
pagesDir: string,
root: string,
): Array<string> {
if (!scan) {
return [];
}
const browserFiles = new Set<string>();
const islandFiles = new Set<string>();
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 : '';

Expand Down
52 changes: 50 additions & 2 deletions packages/react/test/integration/react-pages-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p>{value}</p>; }`,
'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 <><p>{readWorkerValue()}</p><Probe /></>; }`,
'_OptimizerIsland.tsx': `import { value } from '@void-repro/island-only';
export default function Probe() { return <p>{value}</p>; }`,
};
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<string, string> = './index.js',
) {
const packageDir = join(LATE_WORKER_DEPS_ROOT, name);
mkdirSync(packageDir, { recursive: true });
writeFileSync(
Expand All @@ -55,14 +74,24 @@ 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);
}

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';
Expand Down Expand Up @@ -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', () => {
Expand Down
66 changes: 65 additions & 1 deletion packages/react/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <h1>Home</h1>; }');
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: {
Expand All @@ -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<string> }).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 <Counter />; }
`,
);
writePage(
'layout.island.tsx',
`
import Header from '../components/Header.tsx' with { island: 'visible' };
export default function Layout({ children }) { return <><Header />{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<string> }).entries).toHaveLength(2);
});

it('includes only the React entry points needed by each environment', async () => {
const config = (await loadServeConfig()) as {
optimizeDeps: { include: Array<string> };
Expand Down