From 7387b0faef3551001d59bdd9c1c7c455524da12d Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 5 Nov 2026 10:41:01 +0800 Subject: [PATCH] fix(react): serve a workspace-linked void as source in dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React Pages dev listed void/pages-client and void/pages-prefetch (and void/live/client for the worker) in optimizeDeps.include. For a linked void, as in this workspace, that makes Vite pre-bundle a copy of packages/void/dist into node_modules/.vite. Vite keys that cache by the lockfile and config, not by the linked files, so rebuilding void leaves the old copy in place. After #715 added isVoidActionError to void/pages-client and re-exported it from @void/react, a cache built before #715 broke every page: SyntaxError: The requested module '/node_modules/.vite/deps/void_pages-client.js?v=…' does not provide an export named 'isVoidActionError' The client entry never loads, so #app is never marked hydrated. The websockets playground suite timed out on /chat and /notifications locally while CI, which starts with no cache, passed. Include Void's own entries only when they resolve into node_modules. An installed void is still pre-bundled up front, which keeps the #620 cold-start fix. A linked void is served as source, as Vite does for linked packages, so dev always uses the current build. Co-Authored-By: Claude Opus 5.5 --- packages/react/src/plugin-pages.ts | 27 +++++++++++-- packages/react/test/unit/plugin-pages.test.ts | 40 +++++++++++++++---- 2 files changed, 56 insertions(+), 11 deletions(-) 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 };