Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 4 additions & 30 deletions packages/react/src/plugin-pages.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
}

Expand Down
11 changes: 11 additions & 0 deletions packages/react/test/integration/island-css-build.test.ts
Original file line number Diff line number Diff line change
@@ -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' } });
Expand Down Expand Up @@ -79,3 +80,13 @@ export default function Home() { return <Counter />; }`,
build.cleanup();
}
}, 120_000);

definePrivatePagesBuildTest({
packageDir,
pluginName: 'voidReact',
counter: `import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}`,
});
157 changes: 128 additions & 29 deletions packages/react/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ type PagesPluginHooks = {
| HookWithHandler<(id: string) => Promise<string | undefined> | string | undefined>;
};

function loadServeConfig(): Record<string, unknown> {
async function loadServeConfig(): Promise<Record<string, unknown>> {
mkdirSync(PAGES_DIR, { recursive: true });
const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks;
if (typeof plugin.config === 'function') {
return plugin.config({}, { command: 'serve' }) as Record<string, unknown>;
return (await plugin.config({}, { command: 'serve' })) as Record<string, unknown>;
}
return plugin.config?.handler({}, { command: 'serve' }) as Record<string, unknown>;
return (await plugin.config?.handler({}, { command: 'serve' })) as Record<string, unknown>;
}

function getHookHandler<T>(hook: T | HookWithHandler<T> | undefined): T {
Expand Down Expand Up @@ -68,43 +68,142 @@ 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 <h1>Home</h1>; }');
writePage(
helper,
'export default function Helper(props) { return <main>{props.children}</main>; }',
);

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 <h1>Home</h1>; }');
writePage(page, 'export default function Debug() { return <p>Debug</p>; }');

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 <h1>Home</h1>; }',
);
if (ordinary) {
writeFileSync(
join(customPages, 'about.tsx'),
'export default function About() { return <p>About</p>; }',
);
}

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', '<template><p>Other</p></template>'],
['other.svelte', '<p>Other</p>'],
])('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 <h1>Home</h1>; }');
writePage('index.jsx', 'export default function Duplicate() { return <h1>Duplicate</h1>; }');
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 <h1>Home</h1>; }');
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 <h1>Home</h1>; }');
writePage('index.server.tsx', 'export const loader = () => ({});');
writePage('index.server.jsx', 'export const loader = () => ({});');
writePage(page, 'export default function About() { return <h1>About</h1>; }');
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 <h1>Home</h1>; }');
writePage('index.server.tsx', 'export const loader = () => ({});');
writePage('index.server.jsx', 'export const loader = () => ({});');
writePage(page, 'export default function About() { return <h1>About</h1>; }');

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', () => {
afterEach(() => {
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: {
Expand All @@ -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<string> };
ssr?: { optimizeDeps?: { include?: Array<string> } };
environments: {
Expand All @@ -151,7 +250,7 @@ describe('pagesPlugin dev config', () => {
writePage('index.tsx', 'export default function Index() { return <h1>Home</h1>; }\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);

Expand All @@ -170,7 +269,7 @@ describe('pagesPlugin dev config', () => {
writePage('index.tsx', 'export default function Index() { return <h1>Home</h1>; }\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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 <h1>Debug</h1>; }\n');
await getHookHandler(plugin.buildStart).call({});
Expand Down
34 changes: 4 additions & 30 deletions packages/solid/src/plugin-pages.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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)) {
Expand All @@ -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;
}

Expand Down
11 changes: 11 additions & 0 deletions packages/solid/test/integration/island-css-build.test.ts
Original file line number Diff line number Diff line change
@@ -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, '../..'),
Expand Down Expand Up @@ -28,3 +29,13 @@ export default function BadgePage() { return <Badge />; }`,
},
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 <button onClick={() => setCount(count() + 1)}>Count: {count()}</button>;
}`,
});
Loading