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
'],
+ ['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
'],
+ ['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 ;
+}`,
+ '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);
+}