From f15aaf45aaa085759878bfa0cfd58de0ff132f8f Mon Sep 17 00:00:00 2001 From: David Date: Mon, 26 Oct 2026 19:56:08 +0100 Subject: [PATCH] fix(pages): select client entries from the page scan --- packages/react/src/plugin-pages.ts | 34 +--- .../test/integration/island-css-build.test.ts | 11 ++ packages/react/test/unit/plugin-pages.test.ts | 157 ++++++++++++++---- packages/solid/src/plugin-pages.ts | 34 +--- .../test/integration/island-css-build.test.ts | 11 ++ packages/solid/test/unit/plugin-pages.test.ts | 145 +++++++++++++--- test/private-pages-build.ts | 92 ++++++++++ 7 files changed, 372 insertions(+), 112 deletions(-) create mode 100644 test/private-pages-build.ts diff --git a/packages/react/src/plugin-pages.ts b/packages/react/src/plugin-pages.ts index 8746022b3..372098274 100644 --- a/packages/react/src/plugin-pages.ts +++ b/packages/react/src/plugin-pages.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { dirname, relative } from 'pathslash'; import { normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'; import { @@ -38,33 +38,6 @@ type PagesPluginOptions = { prefetch?: PrefetchConfig; }; -/** 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 })) { - if (!entry.isFile()) { - continue; - } - const name = entry.name; - // 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.')) { - return true; - } - } - } catch { - // ignore - } - return false; -} - export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plugin { let pageScan: PageScanResult | null = null; let isDev = false; @@ -175,7 +148,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu viteConfig = config; }, - config(_, env) { + async config(_, env) { // Set before the early return: pages/ may be created after the server starts. isDev = env.command === 'serve'; if (!existsSync(pagesDir)) { @@ -220,7 +193,8 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu } // Skip the pages-client bundle when all pages are island pages - if (!hasNonIslandPages(pagesDir)) { + await runRescan(); + if (!pageScan?.pages.some((page) => !page.island)) { return; } diff --git a/packages/react/test/integration/island-css-build.test.ts b/packages/react/test/integration/island-css-build.test.ts index 97149f117..4c13079c5 100644 --- a/packages/react/test/integration/island-css-build.test.ts +++ b/packages/react/test/integration/island-css-build.test.ts @@ -1,6 +1,7 @@ import { join } from 'node:path'; import { expect, it } from 'vite-plus/test'; import { buildIslandApp, defineIslandCssBuildTest } from '../../../../test/island-css-build.ts'; +import { definePrivatePagesBuildTest } from '../../../../test/private-pages-build.ts'; const packageDir = join(import.meta.dirname, '../..'); const tsconfig = JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }); @@ -79,3 +80,13 @@ export default function Home() { return ; }`, build.cleanup(); } }, 120_000); + +definePrivatePagesBuildTest({ + packageDir, + pluginName: 'voidReact', + counter: `import { useState } from 'react'; +export default function Counter() { + const [count, setCount] = useState(0); + return ; +}`, +}); diff --git a/packages/react/test/unit/plugin-pages.test.ts b/packages/react/test/unit/plugin-pages.test.ts index d297b80b4..365027e65 100644 --- a/packages/react/test/unit/plugin-pages.test.ts +++ b/packages/react/test/unit/plugin-pages.test.ts @@ -20,13 +20,13 @@ type PagesPluginHooks = { | HookWithHandler<(id: string) => Promise | string | undefined>; }; -function loadServeConfig(): Record { +async function loadServeConfig(): Promise> { mkdirSync(PAGES_DIR, { recursive: true }); const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; if (typeof plugin.config === 'function') { - return plugin.config({}, { command: 'serve' }) as Record; + return (await plugin.config({}, { command: 'serve' })) as Record; } - return plugin.config?.handler({}, { command: 'serve' }) as Record; + return (await plugin.config?.handler({}, { command: 'serve' })) as Record; } function getHookHandler(hook: T | HookWithHandler | undefined): T { @@ -68,34 +68,133 @@ describe('pagesPlugin build config', () => { rmSync(TMP, { recursive: true, force: true }); }); + it.each([ + '_ui/frame.tsx', + 'blog/_private/frame.jsx', + '_layouts/docs.tsx', + 'blog/_layouts/docs.jsx', + '_helper.tsx', + ])('omits pages-client for an island-only app with %s', async (helper) => { + writePage('index.island.tsx', 'export default function Home() { return

Home

; }'); + writePage( + helper, + 'export default function Helper(props) { return
{props.children}
; }', + ); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); + }); + + it.each([ + ['debug.dev.tsx', false], + ['debug.prod.tsx', true], + ] as const)('selects pages-client from production pages for %s', async (page, included) => { + writePage('index.island.tsx', 'export default function Home() { return

Home

; }'); + writePage(page, 'export default function Debug() { return

Debug

; }'); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + const config = await getHookHandler(plugin.config)({}, { command: 'build' }); + if (included) { + expect(config).toMatchObject({ + build: { rollupOptions: { input: { 'pages-client': 'virtual:void-pages-client' } } }, + }); + } else { + expect(config).toBeUndefined(); + } + }); + + it.each([false, true])( + 'scans a custom pages root under an underscore path, ordinary page=%s', + async (ordinary) => { + const customPages = join(TMP, '_source', '_views'); + mkdirSync(customPages, { recursive: true }); + writeFileSync( + join(customPages, 'index.island.tsx'), + 'export default function Home() { return

Home

; }', + ); + if (ordinary) { + writeFileSync( + join(customPages, 'about.tsx'), + 'export default function About() { return

About

; }', + ); + } + + const plugin = pagesPlugin(customPages, { root: TMP }) as PagesPluginHooks; + const config = await getHookHandler(plugin.config)({}, { command: 'build' }); + if (ordinary) { + expect(config).toMatchObject({ + build: { rollupOptions: { input: { 'pages-client': 'virtual:void-pages-client' } } }, + }); + } else { + expect(config).toBeUndefined(); + } + }, + ); + + it('omits pages-client for Markdown pages', async () => { + writePage('index.md', '# Home'); + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); + }); + + it.each([ + ['other.vue', ''], + ['other.svelte', '

Other

'], + ])('uses the scanner inventory for a non-island %s component', async (page, component) => { + writePage(page, component); + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toMatchObject({ + build: { rollupOptions: { input: { 'pages-client': 'virtual:void-pages-client' } } }, + }); + }); + + it('returns no build config when the pages directory is missing', async () => { + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); + }); + + it('reports invalid production routes during build configuration', async () => { + writePage('index.tsx', 'export default function Home() { return

Home

; }'); + writePage('index.jsx', 'export default function Duplicate() { return

Duplicate

; }'); + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + await expect(getHookHandler(plugin.config)({}, { command: 'build' })).rejects.toThrow( + 'Multiple page files resolve to the same route', + ); + }); + it.each(['tsx', 'jsx'])( 'omits pages-client for island-only pages with .server.%s files', - (extension) => { + async (extension) => { writePage('index.island.tsx', 'export default function Home() { return

Home

; }'); writePage(`index.server.${extension}`, 'export const loader = () => ({ count: 0 });'); const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; - expect(getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); }, ); - 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

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

About

; }'); + it.each([ + 'about.tsx', + 'about.jsx', + 'blog/about.tsx', + 'blog/about.jsx', + 'status.server.v2.tsx', + 'status.island.v2.tsx', + 'layout.extra.tsx', + ])('keeps pages-client for an ordinary %s page alongside island pages', async (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' } }, - }, - }); - }, - ); + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toMatchObject({ + build: { + manifest: true, + rollupOptions: { input: { 'pages-client': 'virtual:void-pages-client' } }, + }, + }); + }); }); describe('pagesPlugin dev config', () => { @@ -103,8 +202,8 @@ describe('pagesPlugin dev config', () => { rmSync(TMP, { recursive: true, force: true }); }); - it('pre-optimizes React dependencies in the Cloudflare worker environment', () => { - const config = loadServeConfig(); + it('pre-optimizes React dependencies in the Cloudflare worker environment', async () => { + const config = await loadServeConfig(); expect(config).toMatchObject({ environments: { @@ -124,8 +223,8 @@ describe('pagesPlugin dev config', () => { }); }); - it('includes only the React entry points needed by each environment', () => { - const config = loadServeConfig() as { + it('includes only the React entry points needed by each environment', async () => { + const config = (await loadServeConfig()) as { optimizeDeps: { include: Array }; ssr?: { optimizeDeps?: { include?: Array } }; environments: { @@ -151,7 +250,7 @@ describe('pagesPlugin dev config', () => { writePage('index.tsx', 'export default function Index() { return

Home

; }\n'); const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; - getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.config)({}, { command: 'serve' }); await getHookHandler(plugin.buildStart).call({}); const handlers = setupWatcher(plugin); @@ -170,7 +269,7 @@ describe('pagesPlugin dev config', () => { writePage('index.tsx', 'export default function Index() { return

Home

; }\n'); const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; - getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.config)({}, { command: 'serve' }); await getHookHandler(plugin.buildStart).call({}); const logError = vi.fn(); const invalidateModule = vi.fn(); @@ -218,7 +317,7 @@ describe('pagesPlugin environment suffixes', () => { async function loadModules(command: 'serve' | 'build') { const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; - getHookHandler(plugin.config)({}, { command }); + await getHookHandler(plugin.config)({}, { command }); await getHookHandler(plugin.buildStart).call({}); const load = getHookHandler(plugin.load); return { @@ -254,7 +353,7 @@ describe('pagesPlugin environment suffixes', () => { it('scans for dev when pages/ is created after the serve config', async () => { const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; - expect(getHookHandler(plugin.config)({}, { command: 'serve' })).toBeUndefined(); + expect(await getHookHandler(plugin.config)({}, { command: 'serve' })).toBeUndefined(); writePage('debug.dev.tsx', 'export default function Debug() { return

Debug

; }\n'); await getHookHandler(plugin.buildStart).call({}); diff --git a/packages/solid/src/plugin-pages.ts b/packages/solid/src/plugin-pages.ts index 65899de54..74bfc4afe 100644 --- a/packages/solid/src/plugin-pages.ts +++ b/packages/solid/src/plugin-pages.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { dirname } from 'pathslash'; import { normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'; import { @@ -22,33 +22,6 @@ type PagesPluginOptions = { prefetch?: PrefetchConfig; }; -/** 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 })) { - if (!entry.isFile()) { - continue; - } - const name = entry.name; - // 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.')) { - return true; - } - } - } catch { - // ignore - } - return false; -} - export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plugin { let pageScan: PageScanResult | null = null; let isDev = false; @@ -158,7 +131,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu viteConfig = config; }, - config(_, env) { + async config(_, env) { // Set before the early return: pages/ may be created after the server starts. isDev = env.command === 'serve'; if (!existsSync(pagesDir)) { @@ -175,7 +148,8 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu } // Skip the pages-client bundle when all pages are island pages - if (!hasNonIslandPages(pagesDir)) { + await runRescan(); + if (!pageScan?.pages.some((page) => !page.island)) { return; } diff --git a/packages/solid/test/integration/island-css-build.test.ts b/packages/solid/test/integration/island-css-build.test.ts index 0df086474..b621266fe 100644 --- a/packages/solid/test/integration/island-css-build.test.ts +++ b/packages/solid/test/integration/island-css-build.test.ts @@ -1,5 +1,6 @@ import { join } from 'node:path'; import { defineIslandCssBuildTest } from '../../../../test/island-css-build.ts'; +import { definePrivatePagesBuildTest } from '../../../../test/private-pages-build.ts'; defineIslandCssBuildTest({ packageDir: join(import.meta.dirname, '../..'), @@ -28,3 +29,13 @@ export default function BadgePage() { return ; }`, }, expectedCss: ['.shell{outline-offset:7px}', '{text-indent:9px}', '.badge{word-spacing:5px}'], }); + +definePrivatePagesBuildTest({ + packageDir: join(import.meta.dirname, '../..'), + pluginName: 'voidSolid', + counter: `import { createSignal } from 'solid-js'; +export default function Counter() { + const [count, setCount] = createSignal(0); + return ; +}`, +}); diff --git a/packages/solid/test/unit/plugin-pages.test.ts b/packages/solid/test/unit/plugin-pages.test.ts index 632ee463f..16062582a 100644 --- a/packages/solid/test/unit/plugin-pages.test.ts +++ b/packages/solid/test/unit/plugin-pages.test.ts @@ -59,34 +59,133 @@ describe('pagesPlugin build config', () => { rmSync(TMP, { recursive: true, force: true }); }); + it.each([ + '_ui/frame.tsx', + 'blog/_private/frame.jsx', + '_layouts/docs.tsx', + 'blog/_layouts/docs.jsx', + '_helper.tsx', + ])('omits pages-client for an island-only app with %s', async (helper) => { + writePage('index.island.tsx', 'export default function Home() { return

Home

; }'); + writePage( + helper, + 'export default function Helper(props) { return
{props.children}
; }', + ); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); + }); + + it.each([ + ['debug.dev.tsx', false], + ['debug.prod.tsx', true], + ] as const)('selects pages-client from production pages for %s', async (page, included) => { + writePage('index.island.tsx', 'export default function Home() { return

Home

; }'); + writePage(page, 'export default function Debug() { return

Debug

; }'); + + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + const config = await getHookHandler(plugin.config)({}, { command: 'build' }); + if (included) { + expect(config).toMatchObject({ + build: { rollupOptions: { input: { 'pages-client': 'virtual:void-pages-client' } } }, + }); + } else { + expect(config).toBeUndefined(); + } + }); + + it.each([false, true])( + 'scans a custom pages root under an underscore path, ordinary page=%s', + async (ordinary) => { + const customPages = join(TMP, '_source', '_views'); + mkdirSync(customPages, { recursive: true }); + writeFileSync( + join(customPages, 'index.island.tsx'), + 'export default function Home() { return

Home

; }', + ); + if (ordinary) { + writeFileSync( + join(customPages, 'about.tsx'), + 'export default function About() { return

About

; }', + ); + } + + const plugin = pagesPlugin(customPages, { root: TMP }) as PagesPluginHooks; + const config = await getHookHandler(plugin.config)({}, { command: 'build' }); + if (ordinary) { + expect(config).toMatchObject({ + build: { rollupOptions: { input: { 'pages-client': 'virtual:void-pages-client' } } }, + }); + } else { + expect(config).toBeUndefined(); + } + }, + ); + + it('omits pages-client for Markdown pages', async () => { + writePage('index.md', '# Home'); + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); + }); + + it.each([ + ['other.vue', ''], + ['other.svelte', '

Other

'], + ])('uses the scanner inventory for a non-island %s component', async (page, component) => { + writePage(page, component); + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toMatchObject({ + build: { rollupOptions: { input: { 'pages-client': 'virtual:void-pages-client' } } }, + }); + }); + + it('returns no build config when the pages directory is missing', async () => { + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); + }); + + it('reports invalid production routes during build configuration', async () => { + writePage('index.tsx', 'export default function Home() { return

Home

; }'); + writePage('index.jsx', 'export default function Duplicate() { return

Duplicate

; }'); + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + await expect(getHookHandler(plugin.config)({}, { command: 'build' })).rejects.toThrow( + 'Multiple page files resolve to the same route', + ); + }); + it.each(['tsx', 'jsx'])( 'omits pages-client for island-only pages with .server.%s files', - (extension) => { + async (extension) => { writePage('index.island.tsx', 'export default function Home() { return

Home

; }'); writePage(`index.server.${extension}`, 'export const loader = () => ({ count: 0 });'); const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; - expect(getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toBeUndefined(); }, ); - 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

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

About

; }'); + it.each([ + 'about.tsx', + 'about.jsx', + 'blog/about.tsx', + 'blog/about.jsx', + 'status.server.v2.tsx', + 'status.island.v2.tsx', + 'layout.extra.tsx', + ])('keeps pages-client for an ordinary %s page alongside island pages', async (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' } }, - }, - }); - }, - ); + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + expect(await getHookHandler(plugin.config)({}, { command: 'build' })).toMatchObject({ + build: { + manifest: true, + rollupOptions: { input: { 'pages-client': 'virtual:void-pages-client' } }, + }, + }); + }); }); describe('pagesPlugin dev HMR', () => { @@ -98,7 +197,7 @@ describe('pagesPlugin dev HMR', () => { writePage('index.tsx', 'export default function Index() { return

Home

; }\n'); const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; - getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.config)({}, { command: 'serve' }); await getHookHandler(plugin.buildStart).call({}); const handlers = setupWatcher(plugin); @@ -117,7 +216,7 @@ describe('pagesPlugin dev HMR', () => { writePage('index.tsx', 'export default function Index() { return

Home

; }\n'); const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; - getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.config)({}, { command: 'serve' }); await getHookHandler(plugin.buildStart).call({}); const logError = vi.fn(); const invalidateModule = vi.fn(); @@ -163,7 +262,7 @@ describe('pagesPlugin environment suffixes', () => { writePage('home.prod.tsx', 'export default function Page() { return

Prod

; }\n'); const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; - getHookHandler(plugin.config)({}, { command: 'build' }); + await getHookHandler(plugin.config)({}, { command: 'build' }); await getHookHandler(plugin.buildStart).call({}); const load = getHookHandler(plugin.load); @@ -182,7 +281,7 @@ describe('pagesPlugin environment suffixes', () => { writePage('home.prod.tsx', 'export default function Page() { return

Prod

; }\n'); const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; - getHookHandler(plugin.config)({}, { command: 'serve' }); + await getHookHandler(plugin.config)({}, { command: 'serve' }); await getHookHandler(plugin.buildStart).call({}); const load = getHookHandler(plugin.load); @@ -196,7 +295,7 @@ describe('pagesPlugin environment suffixes', () => { it('scans for dev when pages/ is created after the serve config', async () => { const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; - expect(getHookHandler(plugin.config)({}, { command: 'serve' })).toBeUndefined(); + expect(await getHookHandler(plugin.config)({}, { command: 'serve' })).toBeUndefined(); writePage('debug.dev.tsx', 'export default function Page() { return

Debug

; }\n'); await getHookHandler(plugin.buildStart).call({}); diff --git a/test/private-pages-build.ts b/test/private-pages-build.ts new file mode 100644 index 000000000..3d61b3cd4 --- /dev/null +++ b/test/private-pages-build.ts @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { chromium, type Browser } from 'playwright'; +import { preview, type PreviewServer } from 'vite'; +import { expect, it } from 'vite-plus/test'; +import { waitForIslandsHydrated } from './browser-helpers.ts'; +import { buildIslandApp } from './island-css-build.ts'; + +export function definePrivatePagesBuildTest(app: { + packageDir: string; + pluginName: 'voidReact' | 'voidSolid'; + counter: string; +}): void { + it('excludes private helpers from the Pages client and hydrates explicit private islands', async () => { + const classAttribute = app.pluginName === 'voidReact' ? 'className' : 'class'; + const build = buildIslandApp({ + packageDir: app.packageDir, + pluginName: app.pluginName, + files: { + 'tsconfig.json': JSON.stringify({ + compilerOptions: { + jsx: app.pluginName === 'voidReact' ? 'react-jsx' : 'preserve', + jsxImportSource: app.pluginName === 'voidReact' ? 'react' : 'solid-js', + }, + }), + 'pages/_ui/frame.tsx': `import './frame.css'; +export function Frame(props) { + return
{props.children}
; +}`, + 'pages/_ui/frame.css': '.private-frame { outline-offset: 11px; }', + 'pages/_ui/counter.tsx': app.counter, + 'pages/_layouts/docs.tsx': `import './docs.css'; +export default function Docs(props) { + return
{props.children}
; +}`, + 'pages/_layouts/docs.css': '.private-layout { word-spacing: 3px; }', + 'pages/index.island.tsx': `import { Frame } from './_ui/frame'; +import Counter from './_ui/counter' with { island: 'load' }; +export const layout = 'docs'; +export default function Home() { return ; }`, + }, + }); + let server: PreviewServer | undefined; + let browser: Browser | undefined; + try { + const manifest = JSON.parse( + readFileSync(join(build.root, 'dist/client/.vite/manifest.json'), 'utf8'), + ); + expect(Object.values(manifest)).not.toContainEqual( + expect.objectContaining({ name: 'pages-client', isEntry: true }), + ); + expect(Object.values(manifest)).toContainEqual( + expect.objectContaining({ name: 'islands-client', isEntry: true }), + ); + const { html, css } = build.page('index.html'); + expect(html).toContain('data-frame="575_SERVER_FRAME"'); + expect(html).toContain('data-layout="docs"'); + expect(css).toContain('.private-frame{outline-offset:11px}'); + expect(css).toContain('.private-layout{word-spacing:3px}'); + for (const [file, text] of build.output('client')) { + if (file.endsWith('.js')) { + expect(text, file).not.toContain('575_SERVER_FRAME'); + } + } + + server = await preview({ + root: build.root, + configFile: false, + logLevel: 'silent', + build: { outDir: 'dist/client' }, + preview: { host: '127.0.0.1', port: 0 }, + }); + const base = server.resolvedUrls?.local[0]; + if (!base) { + throw new Error('Preview did not report a local URL.'); + } + browser = await chromium.launch(); + const page = await browser.newPage(); + const errors: Array = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(base); + await waitForIslandsHydrated(page, 1, 15_000); + await page.getByRole('button', { name: 'Count: 0', exact: true }).click(); + await expect.poll(() => page.getByRole('button').textContent()).toBe('Count: 1'); + expect(errors).toEqual([]); + } finally { + await browser?.close(); + await server?.close(); + build.cleanup(); + } + }, 120_000); +}