Skip to content

Commit 27ee23e

Browse files
committed
Scan browser roots for React dependency optimization.
1 parent 08a1f46 commit 27ee23e

3 files changed

Lines changed: 164 additions & 22 deletions

File tree

‎packages/react/src/plugin-pages.ts‎

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import { dirname, relative } from 'pathslash';
1+
import { readFileSync } from 'node:fs';
2+
import { dirname, relative, resolve } from 'pathslash';
23
import type { Plugin } from 'vite';
3-
import { createPagesPlugin, generateComponentManifest } from 'void/pages';
4+
import {
5+
createPagesPlugin,
6+
generateComponentManifest,
7+
resolveEffectiveLayoutChain,
8+
} from 'void/pages';
49
import type { PageScanResult, StaticPageDataBuildOptions } from 'void/pages';
10+
import { findIslandImports, resolveSpecifier } from 'void/pages-islands-plugin';
511

612
const CLIENT_ENTRY_ID = 'virtual:void-pages-client';
713
const WORKER_ENVIRONMENT_NAME = 'void_worker';
@@ -34,17 +40,7 @@ type PagesPluginOptions = {
3440

3541
export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plugin {
3642
const root = options?.root ?? dirname(pagesDir);
37-
const pagesDirRelative = relative(root, pagesDir);
38-
const pagesOptimizeEntries = `${pagesDirRelative}/**/*.{js,jsx,ts,tsx}`;
39-
const pagesClientOptimizeEntries = [
40-
pagesOptimizeEntries,
41-
`!${pagesDirRelative}/**/*.server.{js,jsx,ts,tsx}`,
42-
// Island pages render only on the server. Island components in the pages
43-
// directory (`_Counter.tsx`) still match the first pattern.
44-
`!${pagesDirRelative}/**/*.island.{js,jsx,ts,tsx}`,
45-
// Setting entries turns off Vite's default `__tests__` ignore, so keep it.
46-
`!${pagesDirRelative}/**/__tests__/**`,
47-
];
43+
const pagesOptimizeEntries = `${relative(root, pagesDir)}/**/*.{js,jsx,ts,tsx}`;
4844
return createPagesPlugin(pagesDir, {
4945
name: 'void-react:pages',
5046
root,
@@ -74,7 +70,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu
7470
exclude: ['@void/react'],
7571
// Crawl page modules before the first client request so third-party
7672
// imports are pre-bundled instead of triggering a full reload.
77-
entries: pagesClientOptimizeEntries,
73+
entries: clientOptimizeEntries(await scan(), pagesDir, root),
7874
// Pre-include React entries that are discovered late via dynamic
7975
// imports during SPA navigation, avoiding a full-page reload
8076
// from Vite's dep optimizer. void/pages-client and
@@ -124,6 +120,49 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu
124120
});
125121
}
126122

123+
function clientOptimizeEntries(
124+
scan: PageScanResult | null,
125+
pagesDir: string,
126+
root: string,
127+
): Array<string> {
128+
if (!scan) {
129+
return [];
130+
}
131+
const browserFiles = new Set<string>();
132+
const islandFiles = new Set<string>();
133+
for (const page of scan.pages) {
134+
const layouts = resolveEffectiveLayoutChain(page, scan.layouts, scan.namedLayouts);
135+
if (page.island) {
136+
islandFiles.add(resolve(pagesDir, page.componentPath));
137+
for (const layout of layouts) {
138+
if (layout.island) {
139+
islandFiles.add(resolve(pagesDir, layout.filePath));
140+
}
141+
}
142+
} else {
143+
browserFiles.add(resolve(pagesDir, page.componentPath));
144+
for (const layout of layouts) {
145+
browserFiles.add(resolve(pagesDir, layout.filePath));
146+
}
147+
}
148+
}
149+
// Only hydrated components run in the browser; island pages and their
150+
// unmarked imports may depend on Worker-only packages.
151+
for (const file of islandFiles) {
152+
if (!/\.island\.(tsx|jsx)$/.test(file)) {
153+
continue;
154+
}
155+
for (const { specifier } of findIslandImports(readFileSync(file, 'utf8'), file)) {
156+
const component = resolveSpecifier(specifier, dirname(file), ['.tsx', '.jsx', '.ts', '.js']);
157+
if (component) {
158+
browserFiles.add(component);
159+
}
160+
}
161+
}
162+
// Entries are glob patterns, so route names such as [id] must be literal.
163+
return [...browserFiles].map((file) => relative(root, file).replace(/[()[\]{}*?!|]/g, '\\$&'));
164+
}
165+
127166
function generateRendererModule(scan: PageScanResult, pagesDir: string, isDev: boolean): string {
128167
const preambleScript = isDev ? REACT_REFRESH_PREAMBLE : '';
129168

‎packages/react/test/integration/react-pages-client.test.ts‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,31 @@ const ROOT = join(import.meta.dirname, '..', '..', '..', '..');
4141
const REACT_PAGES_ROOT = join(ROOT, 'playground/react-pages');
4242
const REACT_PAGES_LAYOUT = join(REACT_PAGES_ROOT, 'pages/layout.tsx');
4343
const LATE_WORKER_DEPS_ROOT = join(REACT_PAGES_ROOT, 'node_modules/@void-repro');
44+
const OPTIMIZER_PROBE_FILES = {
45+
'_optimizer-helper.ts': `import { value } from '@void-repro/worker-only';
46+
export const readWorkerValue = () => value;`,
47+
'_optimizer-browser.ts': `export { value } from '@void-repro/browser-only';`,
48+
'optimizer-probe/[id].tsx': `import { value } from '../_optimizer-browser';
49+
export default function Probe() { return <p>{value}</p>; }`,
50+
'optimizer-probe/[id].server.ts': `import { defineHandler } from 'void/handler';
51+
import { readWorkerValue } from '../_optimizer-helper';
52+
export const loader = defineHandler(() => ({ value: readWorkerValue() }));`,
53+
'optimizer-island.island.tsx': `import Probe from './_OptimizerIsland' with { island: 'load' };
54+
import { readWorkerValue } from './_optimizer-helper';
55+
export default function IslandPage() { return <><p>{readWorkerValue()}</p><Probe /></>; }`,
56+
'_OptimizerIsland.tsx': `import { value } from '@void-repro/island-only';
57+
export default function Probe() { return <p>{value}</p>; }`,
58+
};
4459
let DEV_BASE: string;
4560
let PREVIEW_BASE: string;
4661

4762
let browser: Browser;
4863

49-
function writeLateWorkerDep(name: string, source: string) {
64+
function writeLateWorkerDep(
65+
name: string,
66+
source: string,
67+
exports: string | Record<string, string> = './index.js',
68+
) {
5069
const packageDir = join(LATE_WORKER_DEPS_ROOT, name);
5170
mkdirSync(packageDir, { recursive: true });
5271
writeFileSync(
@@ -55,14 +74,24 @@ function writeLateWorkerDep(name: string, source: string) {
5574
name: `@void-repro/${name}`,
5675
version: '1.0.0',
5776
type: 'module',
58-
exports: './index.js',
77+
exports,
5978
}),
6079
);
6180
writeFileSync(join(packageDir, 'index.js'), source);
6281
}
6382

6483
function installLateWorkerDeps() {
6584
rmSync(LATE_WORKER_DEPS_ROOT, { recursive: true, force: true });
85+
writeLateWorkerDep('worker-only', 'export const value = "Worker value";', {
86+
workerd: './index.js',
87+
});
88+
writeLateWorkerDep('browser-only', 'export const value = "Browser value";');
89+
writeLateWorkerDep('island-only', 'export const value = "Island value";');
90+
for (const [file, source] of Object.entries(OPTIMIZER_PROBE_FILES)) {
91+
const path = join(REACT_PAGES_ROOT, 'pages', file);
92+
mkdirSync(join(path, '..'), { recursive: true });
93+
writeFileSync(path, source);
94+
}
6695
writeLateWorkerDep(
6796
'boundary',
6897
`import React from 'react';
@@ -740,6 +769,25 @@ describe('react-pages client (dev)', () => {
740769
await killChild(child);
741770
}
742771
rmSync(LATE_WORKER_DEPS_ROOT, { recursive: true, force: true });
772+
for (const file of Object.keys(OPTIMIZER_PROBE_FILES)) {
773+
rmSync(join(REACT_PAGES_ROOT, 'pages', file), { force: true });
774+
}
775+
rmSync(join(REACT_PAGES_ROOT, 'pages/optimizer-probe'), { recursive: true, force: true });
776+
});
777+
778+
it('dev: prebundles browser roots without scanning server-only dependencies', async () => {
779+
await vi.waitFor(() => {
780+
const metadata = JSON.parse(
781+
readFileSync(join(REACT_PAGES_ROOT, 'node_modules/.vite/deps/_metadata.json'), 'utf8'),
782+
);
783+
expect(metadata.optimized).toHaveProperty('@void-repro/browser-only');
784+
expect(metadata.optimized).toHaveProperty('@void-repro/island-only');
785+
expect(metadata.optimized).not.toHaveProperty('@void-repro/worker-only');
786+
});
787+
expect(stderr).not.toContain('Failed to run dependency scan');
788+
const response = await fetch(`${DEV_BASE}/optimizer-probe/example`);
789+
expect(response.status).toBe(200);
790+
expect(await response.text()).toContain('Browser value');
743791
});
744792

745793
it('dev: cold SSR startup does not hit invalid React hook calls', () => {

‎packages/react/test/unit/plugin-pages.test.ts‎

Lines changed: 61 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,16 +203,12 @@ describe('pagesPlugin dev config', () => {
203203
});
204204

205205
it('pre-optimizes page dependencies in the client and Cloudflare worker environments', async () => {
206+
writePage('index.tsx', 'export default function Home() { return <h1>Home</h1>; }');
206207
const config = await loadServeConfig();
207208

208209
expect(config).toMatchObject({
209210
optimizeDeps: {
210-
entries: [
211-
'pages/**/*.{js,jsx,ts,tsx}',
212-
'!pages/**/*.server.{js,jsx,ts,tsx}',
213-
'!pages/**/*.island.{js,jsx,ts,tsx}',
214-
'!pages/**/__tests__/**',
215-
],
211+
entries: ['pages/index.tsx'],
216212
include: expect.arrayContaining(['void/pages-client', 'void/pages-prefetch']),
217213
},
218214
environments: {
@@ -232,6 +228,65 @@ describe('pagesPlugin dev config', () => {
232228
});
233229
});
234230

231+
it('scans browser roots without promoting server-only helpers to client entries', async () => {
232+
writePage('index.tsx', 'export default function Home() { return null; }');
233+
writePage('index.server.ts', 'import "./_worker-helper";');
234+
writePage('_worker-helper.ts', 'import "worker-only-package";');
235+
writePage('layout.tsx', 'export default function Layout({ children }) { return children; }');
236+
writePage(
237+
'users/[id].dev.tsx',
238+
'export const layout = "profile"; export default function User() { return null; }',
239+
);
240+
writePage('users/[id].prod.tsx', 'export default function User() { return null; }');
241+
writePage('_layouts/profile.tsx', 'export default function Profile() { return null; }');
242+
writePage('_layouts/unused.tsx', 'import "worker-only-package";');
243+
writePage('static/layout.tsx', 'import "worker-only-package";');
244+
writePage('static/index.island.tsx', 'export default function Static() { return null; }');
245+
writePage('__tests__/page.test.tsx', 'import "test-only-package";');
246+
247+
const config = await loadServeConfig();
248+
expect(config).toMatchObject({
249+
optimizeDeps: {
250+
entries: expect.arrayContaining([
251+
'pages/index.tsx',
252+
'pages/layout.tsx',
253+
'pages/users/\\[id\\].dev.tsx',
254+
'pages/_layouts/profile.tsx',
255+
]),
256+
},
257+
});
258+
expect((config.optimizeDeps as { entries: Array<string> }).entries).toHaveLength(4);
259+
});
260+
261+
it('scans hydrated components in island pages and layouts, including outside pages', async () => {
262+
writePage(
263+
'index.island.tsx',
264+
`
265+
import Counter from './_Counter' with { island: 'load' };
266+
import './_worker-helper';
267+
export default function Home() { return <Counter />; }
268+
`,
269+
);
270+
writePage(
271+
'layout.island.tsx',
272+
`
273+
import Header from '../components/Header.tsx' with { island: 'visible' };
274+
export default function Layout({ children }) { return <><Header />{children}</>; }
275+
`,
276+
);
277+
writePage('_Counter.tsx', 'export default function Counter() { return null; }');
278+
writePage('../components/Header.tsx', 'export default function Header() { return null; }');
279+
writePage('_worker-helper.ts', 'import "worker-only-package";');
280+
281+
const config = await loadServeConfig();
282+
expect(config).toMatchObject({
283+
optimizeDeps: {
284+
entries: expect.arrayContaining(['pages/_Counter.tsx', 'components/Header.tsx']),
285+
},
286+
});
287+
expect((config.optimizeDeps as { entries: Array<string> }).entries).toHaveLength(2);
288+
});
289+
235290
it('includes only the React entry points needed by each environment', async () => {
236291
const config = (await loadServeConfig()) as {
237292
optimizeDeps: { include: Array<string> };

0 commit comments

Comments
 (0)