From 4efbf00d838c583788b22f0912060f74e59bcccf Mon Sep 17 00:00:00 2001 From: Yangshun Date: Fri, 23 Oct 2026 13:17:00 +0800 Subject: [PATCH 1/3] fix(pages): skip server companions in React and Solid client detection --- packages/react/src/plugin-pages.ts | 4 +- packages/react/test/unit/plugin-pages.test.ts | 60 +++++++++++++++++++ packages/solid/src/plugin-pages.ts | 4 +- packages/solid/test/unit/plugin-pages.test.ts | 60 +++++++++++++++++++ 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/packages/react/src/plugin-pages.ts b/packages/react/src/plugin-pages.ts index dcfd36b31..f449a117e 100644 --- a/packages/react/src/plugin-pages.ts +++ b/packages/react/src/plugin-pages.ts @@ -38,7 +38,7 @@ type PagesPluginOptions = { prefetch?: PrefetchConfig; }; -/** Check if the pages directory has any non-island page files (.tsx/.jsx without .island.). */ +/** Check if the pages directory has any page components that need the full client runtime. */ function hasNonIslandPages(dir: string): boolean { try { for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) { @@ -46,7 +46,7 @@ function hasNonIslandPages(dir: string): boolean { continue; } const name = entry.name; - if (name.startsWith('_') || name.startsWith('layout.')) { + if (name.startsWith('_') || name.startsWith('layout.') || name.includes('.server.')) { continue; } if (/\.(tsx|jsx)$/.test(name) && !name.includes('.island.')) { diff --git a/packages/react/test/unit/plugin-pages.test.ts b/packages/react/test/unit/plugin-pages.test.ts index 246d3cc9a..45bb81b56 100644 --- a/packages/react/test/unit/plugin-pages.test.ts +++ b/packages/react/test/unit/plugin-pages.test.ts @@ -1,6 +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 { islandsPlugin } from '../../src/plugin-islands.ts'; import { pagesPlugin } from '../../src/plugin-pages.ts'; const TMP = join(import.meta.dirname, '__tmp_pages_plugin__'); @@ -63,6 +64,65 @@ function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateMo return handlers; } +describe('pagesPlugin build config', () => { + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it.each(['tsx', 'jsx'])( + 'omits pages-client for island-only pages with .server.%s companions', + async (extension) => { + writePage( + 'index.island.tsx', + `import Counter from '../Counter' with { island: 'load' }; +export default function Home() { return ; }`, + ); + writePage(`index.server.${extension}`, 'export const loader = () => ({ count: 0 });'); + writeFileSync( + join(TMP, 'Counter.tsx'), + 'export default function Counter() { return ; }', + ); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); + + const islandPlugin = islandsPlugin(PAGES_DIR)[0] as PagesPluginHooks; + const islandConfig = await getHookHandler(islandPlugin.config)({}, { command: 'build' }); + expect(islandConfig).toMatchObject({ + environments: { + client: { + build: { + rollupOptions: { input: { 'islands-client': 'virtual:void-islands-client' } }, + }, + }, + }, + }); + await getHookHandler(islandPlugin.buildStart).call({}); + const client = await getHookHandler(islandPlugin.load)('\0virtual:void-islands-client'); + expect(client).toContain('"Counter"'); + expect(client).toContain('initIslands(islands,'); + }, + ); + + it.each(['about.tsx', 'about.jsx', 'blog/about.tsx', 'blog/about.jsx'])( + 'keeps pages-client for an ordinary %s page alongside island pages', + (page) => { + writePage('index.island.tsx', 'export default function Home() { return

Home

; }'); + writePage('index.server.tsx', 'export const loader = () => ({});'); + writePage('index.server.jsx', 'export const loader = () => ({});'); + writePage(page, 'export default function About() { return

About

; }'); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(getHookHandler(plugin.config)({}, { command: 'build' })).toMatchObject({ + build: { + manifest: true, + rollupOptions: { input: { 'pages-client': 'virtual:void-pages-client' } }, + }, + }); + }, + ); +}); + describe('pagesPlugin dev config', () => { afterEach(() => { rmSync(TMP, { recursive: true, force: true }); diff --git a/packages/solid/src/plugin-pages.ts b/packages/solid/src/plugin-pages.ts index 1c4472b97..e2fe48a7a 100644 --- a/packages/solid/src/plugin-pages.ts +++ b/packages/solid/src/plugin-pages.ts @@ -22,7 +22,7 @@ type PagesPluginOptions = { prefetch?: PrefetchConfig; }; -/** Check if the pages directory has any non-island page files (.tsx/.jsx without .island.). */ +/** Check if the pages directory has any page components that need the full client runtime. */ function hasNonIslandPages(dir: string): boolean { try { for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) { @@ -30,7 +30,7 @@ function hasNonIslandPages(dir: string): boolean { continue; } const name = entry.name; - if (name.startsWith('_') || name.startsWith('layout.')) { + if (name.startsWith('_') || name.startsWith('layout.') || name.includes('.server.')) { continue; } if (/\.(tsx|jsx)$/.test(name) && !name.includes('.island.')) { diff --git a/packages/solid/test/unit/plugin-pages.test.ts b/packages/solid/test/unit/plugin-pages.test.ts index b31feba83..b495a4f6e 100644 --- a/packages/solid/test/unit/plugin-pages.test.ts +++ b/packages/solid/test/unit/plugin-pages.test.ts @@ -1,6 +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 { islandsPlugin } from '../../src/plugin-islands.ts'; import { pagesPlugin } from '../../src/plugin-pages.ts'; const TMP = join(import.meta.dirname, '__tmp_pages_plugin__'); @@ -54,6 +55,65 @@ function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateMo return handlers; } +describe('pagesPlugin build config', () => { + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it.each(['tsx', 'jsx'])( + 'omits pages-client for island-only pages with .server.%s companions', + async (extension) => { + writePage( + 'index.island.tsx', + `import Counter from '../Counter' with { island: 'load' }; +export default function Home() { return ; }`, + ); + writePage(`index.server.${extension}`, 'export const loader = () => ({ count: 0 });'); + writeFileSync( + join(TMP, 'Counter.tsx'), + 'export default function Counter() { return ; }', + ); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); + + const islandPlugin = islandsPlugin(PAGES_DIR)[0] as PagesPluginHooks; + const islandConfig = await getHookHandler(islandPlugin.config)({}, { command: 'build' }); + expect(islandConfig).toMatchObject({ + environments: { + client: { + build: { + rollupOptions: { input: { 'islands-client': 'virtual:void-islands-client' } }, + }, + }, + }, + }); + await getHookHandler(islandPlugin.buildStart).call({}); + const client = await getHookHandler(islandPlugin.load)('\0virtual:void-islands-client'); + expect(client).toContain('"Counter"'); + expect(client).toContain('initIslands(islands,'); + }, + ); + + it.each(['about.tsx', 'about.jsx', 'blog/about.tsx', 'blog/about.jsx'])( + 'keeps pages-client for an ordinary %s page alongside island pages', + (page) => { + writePage('index.island.tsx', 'export default function Home() { return

Home

; }'); + writePage('index.server.tsx', 'export const loader = () => ({});'); + writePage('index.server.jsx', 'export const loader = () => ({});'); + writePage(page, 'export default function About() { return

About

; }'); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(getHookHandler(plugin.config)({}, { command: 'build' })).toMatchObject({ + build: { + manifest: true, + rollupOptions: { input: { 'pages-client': 'virtual:void-pages-client' } }, + }, + }); + }, + ); +}); + describe('pagesPlugin dev HMR', () => { afterEach(() => { rmSync(TMP, { recursive: true, force: true }); From 1b51a55540d3d5a39c00a95a392cf885e7755447 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Fri, 23 Oct 2026 13:23:25 +0800 Subject: [PATCH 2/3] test(pages): clarify JSX server filename coverage --- packages/react/test/unit/plugin-pages.test.ts | 2 +- packages/solid/test/unit/plugin-pages.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react/test/unit/plugin-pages.test.ts b/packages/react/test/unit/plugin-pages.test.ts index 45bb81b56..f02b979f3 100644 --- a/packages/react/test/unit/plugin-pages.test.ts +++ b/packages/react/test/unit/plugin-pages.test.ts @@ -70,7 +70,7 @@ describe('pagesPlugin build config', () => { }); it.each(['tsx', 'jsx'])( - 'omits pages-client for island-only pages with .server.%s companions', + 'omits pages-client for island-only pages with .server.%s files', async (extension) => { writePage( 'index.island.tsx', diff --git a/packages/solid/test/unit/plugin-pages.test.ts b/packages/solid/test/unit/plugin-pages.test.ts index b495a4f6e..e89fbe8ea 100644 --- a/packages/solid/test/unit/plugin-pages.test.ts +++ b/packages/solid/test/unit/plugin-pages.test.ts @@ -61,7 +61,7 @@ describe('pagesPlugin build config', () => { }); it.each(['tsx', 'jsx'])( - 'omits pages-client for island-only pages with .server.%s companions', + 'omits pages-client for island-only pages with .server.%s files', async (extension) => { writePage( 'index.island.tsx', From 3bd6d0ba38d4a14edc5214f5c34c1043e138f857 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 26 Oct 2026 12:44:01 +0800 Subject: [PATCH 3/3] fix(pages): skip only files that end in .server.tsx or .server.jsx The check skipped any name containing `.server.`, so a page such as `status.server.v2.tsx` lost its pages-client bundle in an island-only app, while Void's page scanner still serves it as a page. Match the scanner: only `.server.tsx` and `.server.jsx` are server companions. Drop the islands plugin checks from the new test: they pass with or without this change. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/react/src/plugin-pages.ts | 8 ++++- packages/react/test/unit/plugin-pages.test.ts | 31 ++----------------- packages/solid/src/plugin-pages.ts | 8 ++++- packages/solid/test/unit/plugin-pages.test.ts | 31 ++----------------- 4 files changed, 20 insertions(+), 58 deletions(-) diff --git a/packages/react/src/plugin-pages.ts b/packages/react/src/plugin-pages.ts index f449a117e..1e38ad0d6 100644 --- a/packages/react/src/plugin-pages.ts +++ b/packages/react/src/plugin-pages.ts @@ -46,7 +46,13 @@ function hasNonIslandPages(dir: string): boolean { continue; } const name = entry.name; - if (name.startsWith('_') || name.startsWith('layout.') || name.includes('.server.')) { + // Server companions such as `index.server.tsx` are not pages. + if ( + name.startsWith('_') || + name.startsWith('layout.') || + name.endsWith('.server.tsx') || + name.endsWith('.server.jsx') + ) { continue; } if (/\.(tsx|jsx)$/.test(name) && !name.includes('.island.')) { diff --git a/packages/react/test/unit/plugin-pages.test.ts b/packages/react/test/unit/plugin-pages.test.ts index f02b979f3..11d6de15a 100644 --- a/packages/react/test/unit/plugin-pages.test.ts +++ b/packages/react/test/unit/plugin-pages.test.ts @@ -1,7 +1,6 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; -import { islandsPlugin } from '../../src/plugin-islands.ts'; import { pagesPlugin } from '../../src/plugin-pages.ts'; const TMP = join(import.meta.dirname, '__tmp_pages_plugin__'); @@ -71,40 +70,16 @@ describe('pagesPlugin build config', () => { it.each(['tsx', 'jsx'])( 'omits pages-client for island-only pages with .server.%s files', - async (extension) => { - writePage( - 'index.island.tsx', - `import Counter from '../Counter' with { island: 'load' }; -export default function Home() { return ; }`, - ); + (extension) => { + writePage('index.island.tsx', 'export default function Home() { return

Home

; }'); writePage(`index.server.${extension}`, 'export const loader = () => ({ count: 0 });'); - writeFileSync( - join(TMP, 'Counter.tsx'), - 'export default function Counter() { return ; }', - ); const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; expect(getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); - - const islandPlugin = islandsPlugin(PAGES_DIR)[0] as PagesPluginHooks; - const islandConfig = await getHookHandler(islandPlugin.config)({}, { command: 'build' }); - expect(islandConfig).toMatchObject({ - environments: { - client: { - build: { - rollupOptions: { input: { 'islands-client': 'virtual:void-islands-client' } }, - }, - }, - }, - }); - await getHookHandler(islandPlugin.buildStart).call({}); - const client = await getHookHandler(islandPlugin.load)('\0virtual:void-islands-client'); - expect(client).toContain('"Counter"'); - expect(client).toContain('initIslands(islands,'); }, ); - it.each(['about.tsx', 'about.jsx', 'blog/about.tsx', 'blog/about.jsx'])( + it.each(['about.tsx', 'about.jsx', 'blog/about.tsx', 'blog/about.jsx', 'status.server.v2.tsx'])( 'keeps pages-client for an ordinary %s page alongside island pages', (page) => { writePage('index.island.tsx', 'export default function Home() { return

Home

; }'); diff --git a/packages/solid/src/plugin-pages.ts b/packages/solid/src/plugin-pages.ts index e2fe48a7a..6195176e1 100644 --- a/packages/solid/src/plugin-pages.ts +++ b/packages/solid/src/plugin-pages.ts @@ -30,7 +30,13 @@ function hasNonIslandPages(dir: string): boolean { continue; } const name = entry.name; - if (name.startsWith('_') || name.startsWith('layout.') || name.includes('.server.')) { + // Server companions such as `index.server.tsx` are not pages. + if ( + name.startsWith('_') || + name.startsWith('layout.') || + name.endsWith('.server.tsx') || + name.endsWith('.server.jsx') + ) { continue; } if (/\.(tsx|jsx)$/.test(name) && !name.includes('.island.')) { diff --git a/packages/solid/test/unit/plugin-pages.test.ts b/packages/solid/test/unit/plugin-pages.test.ts index e89fbe8ea..c39dbbf6e 100644 --- a/packages/solid/test/unit/plugin-pages.test.ts +++ b/packages/solid/test/unit/plugin-pages.test.ts @@ -1,7 +1,6 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; -import { islandsPlugin } from '../../src/plugin-islands.ts'; import { pagesPlugin } from '../../src/plugin-pages.ts'; const TMP = join(import.meta.dirname, '__tmp_pages_plugin__'); @@ -62,40 +61,16 @@ describe('pagesPlugin build config', () => { it.each(['tsx', 'jsx'])( 'omits pages-client for island-only pages with .server.%s files', - async (extension) => { - writePage( - 'index.island.tsx', - `import Counter from '../Counter' with { island: 'load' }; -export default function Home() { return ; }`, - ); + (extension) => { + writePage('index.island.tsx', 'export default function Home() { return

Home

; }'); writePage(`index.server.${extension}`, 'export const loader = () => ({ count: 0 });'); - writeFileSync( - join(TMP, 'Counter.tsx'), - 'export default function Counter() { return ; }', - ); const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; expect(getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); - - const islandPlugin = islandsPlugin(PAGES_DIR)[0] as PagesPluginHooks; - const islandConfig = await getHookHandler(islandPlugin.config)({}, { command: 'build' }); - expect(islandConfig).toMatchObject({ - environments: { - client: { - build: { - rollupOptions: { input: { 'islands-client': 'virtual:void-islands-client' } }, - }, - }, - }, - }); - await getHookHandler(islandPlugin.buildStart).call({}); - const client = await getHookHandler(islandPlugin.load)('\0virtual:void-islands-client'); - expect(client).toContain('"Counter"'); - expect(client).toContain('initIslands(islands,'); }, ); - it.each(['about.tsx', 'about.jsx', 'blog/about.tsx', 'blog/about.jsx'])( + it.each(['about.tsx', 'about.jsx', 'blog/about.tsx', 'blog/about.jsx', 'status.server.v2.tsx'])( 'keeps pages-client for an ordinary %s page alongside island pages', (page) => { writePage('index.island.tsx', 'export default function Home() { return

Home

; }');