diff --git a/packages/react/src/plugin-pages.ts b/packages/react/src/plugin-pages.ts index c87555ace..4b21061cc 100644 --- a/packages/react/src/plugin-pages.ts +++ b/packages/react/src/plugin-pages.ts @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import { dirname, relative, resolve } from 'pathslash'; import type { Plugin } from 'vite'; import { @@ -22,7 +23,8 @@ const WORKER_ENVIRONMENT_NAME = 'void_worker'; const REACT_DEDUPE = ['react', 'react-dom']; const REACT_OPTIMIZE_DEPS = ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom']; const CLIENT_OPTIMIZE_DEPS = [...REACT_OPTIMIZE_DEPS, 'react-dom/client']; -const WORKER_OPTIMIZE_DEPS = [...REACT_OPTIMIZE_DEPS, 'react-dom/server', 'void/live/client']; +const WORKER_OPTIMIZE_DEPS = [...REACT_OPTIMIZE_DEPS, 'react-dom/server']; +const NODE_MODULES_SEGMENT = /[\\/]node_modules[\\/]/; // SSR HTML bypasses transformIndexHtml, so @vitejs/plugin-react cannot inject // its Fast Refresh preamble. Without it, the dev client entry fails to load. @@ -73,7 +75,10 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu // 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'], + include: [ + ...CLIENT_OPTIMIZE_DEPS, + ...installedDependencies(['void/pages-client', 'void/pages-prefetch']), + ], }, ssr: { optimizeDeps: { @@ -89,7 +94,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu // SSR is already rendering, the program reload can mix React // and react-dom/server from different optimized-dep hashes. entries: [pagesOptimizeEntries], - include: WORKER_OPTIMIZE_DEPS, + include: [...WORKER_OPTIMIZE_DEPS, ...installedDependencies(['void/live/client'])], }, }, }, @@ -117,6 +122,22 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu }); } +/** + * Keeps the specifiers that resolve into `node_modules`. Vite serves a linked + * package, such as `void` in a workspace, as source unless + * `optimizeDeps.include` lists it. A listed linked package is pre-bundled into + * a cache keyed by the lockfile and config rather than the package's files, so + * dev would keep serving that copy after the package is rebuilt. A client built + * against the new exports then fails to load and never hydrates. + */ +export function installedDependencies( + specifiers: ReadonlyArray, + resolveFile: (specifier: string) => string = (specifier) => + fileURLToPath(import.meta.resolve(specifier)), +): Array { + return specifiers.filter((specifier) => NODE_MODULES_SEGMENT.test(resolveFile(specifier))); +} + function clientOptimizeEntries( scan: PageScanResult | null, pagesDir: string, diff --git a/packages/react/test/unit/plugin-pages.test.ts b/packages/react/test/unit/plugin-pages.test.ts index ef4c5a2ca..58a6767aa 100644 --- a/packages/react/test/unit/plugin-pages.test.ts +++ b/packages/react/test/unit/plugin-pages.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; -import { pagesPlugin } from '../../src/plugin-pages.ts'; +import { installedDependencies, pagesPlugin } from '../../src/plugin-pages.ts'; const TMP = join(import.meta.dirname, '__tmp_pages_plugin__'); const PAGES_DIR = join(TMP, 'pages'); @@ -197,6 +197,22 @@ describe('pagesPlugin build config', () => { }); }); +describe('installedDependencies', () => { + it('keeps entries installed in node_modules and drops linked workspace entries', () => { + const files: Record = { + 'void/pages-client': + '/app/node_modules/.pnpm/void@1.6.2/node_modules/void/dist/pages/client.mjs', + 'void/pages-prefetch': 'C:\\app\\node_modules\\void\\dist\\pages\\prefetch.mjs', + 'void/live/client': '/repo/packages/void/dist/runtime/live-client.mjs', + }; + + expect(installedDependencies(Object.keys(files), (specifier) => files[specifier]!)).toEqual([ + 'void/pages-client', + 'void/pages-prefetch', + ]); + }); +}); + describe('pagesPlugin dev config', () => { afterEach(() => { rmSync(TMP, { recursive: true, force: true }); @@ -209,19 +225,14 @@ describe('pagesPlugin dev config', () => { expect(config).toMatchObject({ optimizeDeps: { entries: ['pages/index.tsx'], - include: expect.arrayContaining(['void/pages-client', 'void/pages-prefetch']), + include: expect.arrayContaining(['react', 'react-dom/client']), }, environments: { void_worker: { optimizeDeps: { exclude: ['@void/react'], entries: ['pages/**/*.{js,jsx,ts,tsx}'], - include: expect.arrayContaining([ - 'react', - 'react-dom', - 'react-dom/server', - 'void/live/client', - ]), + include: expect.arrayContaining(['react', 'react-dom', 'react-dom/server']), }, }, }, @@ -287,6 +298,19 @@ describe('pagesPlugin dev config', () => { expect((config.optimizeDeps as { entries: Array }).entries).toHaveLength(2); }); + it('serves the workspace-linked void as source instead of pre-bundling it', async () => { + // This repository links void from packages/void, so a pre-bundled copy would + // outlive the next void build. + const config = (await loadServeConfig()) as { + optimizeDeps: { include: Array }; + environments: { void_worker: { optimizeDeps: { include: Array } } }; + }; + + expect(config.optimizeDeps.include).not.toContain('void/pages-client'); + expect(config.optimizeDeps.include).not.toContain('void/pages-prefetch'); + expect(config.environments.void_worker.optimizeDeps.include).not.toContain('void/live/client'); + }); + it('includes only the React entry points needed by each environment', async () => { const config = (await loadServeConfig()) as { optimizeDeps: { include: Array };