From d007689322c234fadce5cd879d1766dbacece8c0 Mon Sep 17 00:00:00 2001 From: Yangshun Date: Thu, 29 Oct 2026 14:55:11 +0800 Subject: [PATCH] refactor(pages): consolidate adapter plugin lifecycle --- packages/react/src/plugin-pages.ts | 138 ++---------- packages/solid/src/plugin-pages.ts | 140 ++---------- packages/svelte/src/plugin-pages.ts | 139 ++---------- packages/void/src/pages/index.ts | 1 + packages/void/src/pages/plugin.ts | 149 ++++++++++++ .../test/unit/pages-plugin-adapters.test.ts | 213 ++++++++++++++++++ packages/void/test/unit/pages-plugin.test.ts | 138 ++++++++++++ packages/vue/src/plugin-pages.ts | 138 ++---------- 8 files changed, 569 insertions(+), 487 deletions(-) create mode 100644 packages/void/src/pages/plugin.ts create mode 100644 packages/void/test/unit/pages-plugin-adapters.test.ts create mode 100644 packages/void/test/unit/pages-plugin.test.ts diff --git a/packages/react/src/plugin-pages.ts b/packages/react/src/plugin-pages.ts index 372098274..1c847f02f 100644 --- a/packages/react/src/plugin-pages.ts +++ b/packages/react/src/plugin-pages.ts @@ -1,14 +1,8 @@ -import { existsSync } from 'node:fs'; import { dirname, relative } from 'pathslash'; -import { normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'; -import { - generateComponentManifest, - resolveStaticPageDataBuildOptions, - scanPages, -} from 'void/pages'; +import type { Plugin } from 'vite'; +import { createPagesPlugin, generateComponentManifest } from 'void/pages'; import type { PageScanResult, StaticPageDataBuildOptions } from 'void/pages'; -const RENDERER_ID = 'virtual:void-pages-renderer'; const CLIENT_ENTRY_ID = 'virtual:void-pages-client'; const WORKER_ENVIRONMENT_NAME = 'void_worker'; const REACT_DEDUPE = ['react', 'react-dom']; @@ -39,122 +33,24 @@ type PagesPluginOptions = { }; export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plugin { - let pageScan: PageScanResult | null = null; - let isDev = false; - let viteConfig: ResolvedConfig | undefined; const root = options?.root ?? dirname(pagesDir); - const normalizedPagesDir = normalizePath(pagesDir); const pagesOptimizeEntries = `${relative(root, pagesDir)}/**/*.{js,jsx,ts,tsx}`; - let pendingRescan: Promise | null = null; - const rescanPages = async () => { - pageScan = existsSync(pagesDir) - ? await scanPages(root, { pagesDir, environment: isDev ? 'dev' : 'prod' }) - : null; - }; - const runRescan = () => { - pendingRescan ??= rescanPages().finally(() => { - pendingRescan = null; - }); - return pendingRescan; - }; - - const invalidatePageModules = (server: ViteDevServer) => { - for (const env of Object.values(server.environments)) { - for (const id of ['\0' + RENDERER_ID, '\0' + CLIENT_ENTRY_ID]) { - const mod = env.moduleGraph.getModuleById(id); - if (mod) { - env.moduleGraph.invalidateModule(mod); - } - } - } - }; - - return { + return createPagesPlugin(pagesDir, { name: 'void-react:pages', - - async buildStart() { - await runRescan(); + root, + generateRenderer(scan, isDev) { + return generateRendererModule(scan, pagesDir, isDev); }, - - resolveId: { - filter: { id: /^virtual:void-pages-(renderer|client)$/ }, - handler(id) { - return '\0' + id; - }, + generateClient(scan, staticPageData) { + return generateClientEntry( + scan, + pagesDir, + options?.viewTransitions, + options?.prefetch, + staticPageData, + ); }, - - load: { - // eslint-disable-next-line no-control-regex -- Vite virtual module null-byte prefix - filter: { id: /^\0virtual:void-pages-(renderer|client)$/ }, - async handler(id) { - if (isDev && pendingRescan) { - await pendingRescan; - } - if (!pageScan && existsSync(pagesDir)) { - await runRescan(); - } - if (id === '\0' + RENDERER_ID) { - if (!pageScan) { - return "export function renderPage() { throw new Error('No pages found'); }"; - } - return generateRendererModule(pageScan, pagesDir, isDev); - } - if (id === '\0' + CLIENT_ENTRY_ID) { - if (!pageScan) { - return ''; - } - return generateClientEntry( - pageScan, - pagesDir, - options?.viewTransitions, - options?.prefetch, - resolveStaticPageDataBuildOptions(root, { publicDir: viteConfig?.publicDir }), - ); - } - }, - }, - - configureServer(server) { - const handlePagesChange = async (path: string, event: 'add' | 'change' | 'unlink') => { - const normalizedPath = normalizePath(path); - if ( - normalizedPath !== normalizedPagesDir && - !normalizedPath.startsWith(normalizedPagesDir + '/') - ) { - return; - } - if (event === 'change' && !normalizedPath.endsWith('.md')) { - return; - } - try { - await runRescan(); - } catch (error) { - // Keep the last good scan; the next change rescans. - server.config.logger.error(error instanceof Error ? error.message : String(error), { - error: error instanceof Error ? error : undefined, - timestamp: true, - }); - return; - } - invalidatePageModules(server); - }; - - server.watcher.on('add', (path) => handlePagesChange(path, 'add')); - server.watcher.on('change', (path) => handlePagesChange(path, 'change')); - server.watcher.on('unlink', (path) => handlePagesChange(path, 'unlink')); - }, - - configResolved(config) { - viteConfig = config; - }, - - async config(_, env) { - // Set before the early return: pages/ may be created after the server starts. - isDev = env.command === 'serve'; - if (!existsSync(pagesDir)) { - return; - } - + async config({ isDev, scan }) { // In dev mode, exclude @void/react from dep optimization so the // virtual module and user components share the same React context objects. // Without this, Vite pre-bundles @void/react, creating separate @@ -193,7 +89,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu } // Skip the pages-client bundle when all pages are island pages - await runRescan(); + const pageScan = await scan(); if (!pageScan?.pages.some((page) => !page.island)) { return; } @@ -210,7 +106,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu }, }; }, - }; + }); } function generateRendererModule(scan: PageScanResult, pagesDir: string, isDev: boolean): string { diff --git a/packages/solid/src/plugin-pages.ts b/packages/solid/src/plugin-pages.ts index 74bfc4afe..6ca45d10e 100644 --- a/packages/solid/src/plugin-pages.ts +++ b/packages/solid/src/plugin-pages.ts @@ -1,14 +1,7 @@ -import { existsSync } from 'node:fs'; -import { dirname } from 'pathslash'; -import { normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'; -import { - generateComponentManifest, - resolveStaticPageDataBuildOptions, - scanPages, -} from 'void/pages'; +import type { Plugin } from 'vite'; +import { createPagesPlugin, generateComponentManifest } from 'void/pages'; import type { PageScanResult, StaticPageDataBuildOptions } from 'void/pages'; -const RENDERER_ID = 'virtual:void-pages-renderer'; const CLIENT_ENTRY_ID = 'virtual:void-pages-client'; export type PrefetchConfig = { @@ -23,121 +16,22 @@ type PagesPluginOptions = { }; export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plugin { - let pageScan: PageScanResult | null = null; - let isDev = false; - let viteConfig: ResolvedConfig | undefined; - const root = options?.root ?? dirname(pagesDir); - const normalizedPagesDir = normalizePath(pagesDir); - let pendingRescan: Promise | null = null; - const rescanPages = async () => { - pageScan = existsSync(pagesDir) - ? await scanPages(root, { pagesDir, environment: isDev ? 'dev' : 'prod' }) - : null; - }; - const runRescan = () => { - pendingRescan ??= rescanPages().finally(() => { - pendingRescan = null; - }); - return pendingRescan; - }; - - const invalidatePageModules = (server: ViteDevServer) => { - for (const env of Object.values(server.environments)) { - for (const id of ['\0' + RENDERER_ID, '\0' + CLIENT_ENTRY_ID]) { - const mod = env.moduleGraph.getModuleById(id); - if (mod) { - env.moduleGraph.invalidateModule(mod); - } - } - } - }; - - return { + return createPagesPlugin(pagesDir, { name: 'void-solid:pages', - - async buildStart() { - await runRescan(); + root: options?.root, + generateRenderer(scan) { + return generateRendererModule(scan, pagesDir); }, - - resolveId: { - filter: { id: /^virtual:void-pages-(renderer|client)$/ }, - handler(id) { - return '\0' + id; - }, + generateClient(scan, staticPageData) { + return generateClientEntry( + scan, + pagesDir, + options?.viewTransitions, + options?.prefetch, + staticPageData, + ); }, - - load: { - // eslint-disable-next-line no-control-regex -- Vite virtual module null-byte prefix - filter: { id: /^\0virtual:void-pages-(renderer|client)$/ }, - async handler(id) { - if (isDev && pendingRescan) { - await pendingRescan; - } - if (!pageScan && existsSync(pagesDir)) { - await runRescan(); - } - if (id === '\0' + RENDERER_ID) { - if (!pageScan) { - return "export function renderPage() { throw new Error('No pages found'); }"; - } - return generateRendererModule(pageScan, pagesDir); - } - if (id === '\0' + CLIENT_ENTRY_ID) { - if (!pageScan) { - return ''; - } - return generateClientEntry( - pageScan, - pagesDir, - options?.viewTransitions, - options?.prefetch, - resolveStaticPageDataBuildOptions(root, { publicDir: viteConfig?.publicDir }), - ); - } - }, - }, - - configureServer(server) { - const handlePagesChange = async (path: string, event: 'add' | 'change' | 'unlink') => { - const normalizedPath = normalizePath(path); - if ( - normalizedPath !== normalizedPagesDir && - !normalizedPath.startsWith(normalizedPagesDir + '/') - ) { - return; - } - if (event === 'change' && !normalizedPath.endsWith('.md')) { - return; - } - try { - await runRescan(); - } catch (error) { - // Keep the last good scan; the next change rescans. - server.config.logger.error(error instanceof Error ? error.message : String(error), { - error: error instanceof Error ? error : undefined, - timestamp: true, - }); - return; - } - invalidatePageModules(server); - }; - - server.watcher.on('add', (path) => handlePagesChange(path, 'add')); - server.watcher.on('change', (path) => handlePagesChange(path, 'change')); - server.watcher.on('unlink', (path) => handlePagesChange(path, 'unlink')); - }, - - configResolved(config) { - viteConfig = config; - }, - - async config(_, env) { - // Set before the early return: pages/ may be created after the server starts. - isDev = env.command === 'serve'; - if (!existsSync(pagesDir)) { - return; - } - + async config({ isDev, scan }) { if (isDev) { return { optimizeDeps: { @@ -148,7 +42,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu } // Skip the pages-client bundle when all pages are island pages - await runRescan(); + const pageScan = await scan(); if (!pageScan?.pages.some((page) => !page.island)) { return; } @@ -162,7 +56,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu }, }; }, - }; + }); } function generateRendererModule(scan: PageScanResult, pagesDir: string): string { diff --git a/packages/svelte/src/plugin-pages.ts b/packages/svelte/src/plugin-pages.ts index cba27c0ec..4177424cd 100644 --- a/packages/svelte/src/plugin-pages.ts +++ b/packages/svelte/src/plugin-pages.ts @@ -1,14 +1,8 @@ -import { existsSync, readdirSync } from 'node:fs'; -import { dirname } from 'pathslash'; -import { normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'; -import { - generateComponentManifest, - resolveStaticPageDataBuildOptions, - scanPages, -} from 'void/pages'; +import { readdirSync } from 'node:fs'; +import type { Plugin } from 'vite'; +import { createPagesPlugin, generateComponentManifest } from 'void/pages'; import type { PageScanResult, StaticPageDataBuildOptions } from 'void/pages'; -const RENDERER_ID = 'virtual:void-pages-renderer'; const CLIENT_ENTRY_ID = 'virtual:void-pages-client'; const SVELTE_OPTIMIZE_DEPS = { // Exclude @void/svelte so the virtual module and user components share @@ -51,121 +45,22 @@ function hasNonIslandPages(dir: string): boolean { } export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plugin { - let pageScan: PageScanResult | null = null; - let isDev = false; - let viteConfig: ResolvedConfig | undefined; - const root = options?.root ?? dirname(pagesDir); - const normalizedPagesDir = normalizePath(pagesDir); - let pendingRescan: Promise | null = null; - const rescanPages = async () => { - pageScan = existsSync(pagesDir) - ? await scanPages(root, { pagesDir, environment: isDev ? 'dev' : 'prod' }) - : null; - }; - const runRescan = () => { - pendingRescan ??= rescanPages().finally(() => { - pendingRescan = null; - }); - return pendingRescan; - }; - - const invalidatePageModules = (server: ViteDevServer) => { - for (const env of Object.values(server.environments)) { - for (const id of ['\0' + RENDERER_ID, '\0' + CLIENT_ENTRY_ID]) { - const mod = env.moduleGraph.getModuleById(id); - if (mod) { - env.moduleGraph.invalidateModule(mod); - } - } - } - }; - - return { + return createPagesPlugin(pagesDir, { name: 'void-svelte:pages', - - async buildStart() { - await runRescan(); + root: options?.root, + generateRenderer(scan) { + return generateRendererModule(scan, pagesDir); }, - - resolveId: { - filter: { id: /^virtual:void-pages-(renderer|client)$/ }, - handler(id) { - return '\0' + id; - }, + generateClient(scan, staticPageData) { + return generateClientEntry( + scan, + pagesDir, + options?.viewTransitions, + options?.prefetch, + staticPageData, + ); }, - - load: { - // eslint-disable-next-line no-control-regex -- Vite virtual module null-byte prefix - filter: { id: /^\0virtual:void-pages-(renderer|client)$/ }, - async handler(id) { - if (isDev && pendingRescan) { - await pendingRescan; - } - if (!pageScan && existsSync(pagesDir)) { - await runRescan(); - } - if (id === '\0' + RENDERER_ID) { - if (!pageScan) { - return "export function renderPage() { throw new Error('No pages found'); }"; - } - return generateRendererModule(pageScan, pagesDir); - } - if (id === '\0' + CLIENT_ENTRY_ID) { - if (!pageScan) { - return ''; - } - return generateClientEntry( - pageScan, - pagesDir, - options?.viewTransitions, - options?.prefetch, - resolveStaticPageDataBuildOptions(root, { publicDir: viteConfig?.publicDir }), - ); - } - }, - }, - - configureServer(server) { - const handlePagesChange = async (path: string, event: 'add' | 'change' | 'unlink') => { - const normalizedPath = normalizePath(path); - if ( - normalizedPath !== normalizedPagesDir && - !normalizedPath.startsWith(normalizedPagesDir + '/') - ) { - return; - } - if (event === 'change' && !normalizedPath.endsWith('.md')) { - return; - } - try { - await runRescan(); - } catch (error) { - // Keep the last good scan; the next change rescans. - server.config.logger.error(error instanceof Error ? error.message : String(error), { - error: error instanceof Error ? error : undefined, - timestamp: true, - }); - return; - } - invalidatePageModules(server); - }; - - server.watcher.on('add', (path) => handlePagesChange(path, 'add')); - server.watcher.on('change', (path) => handlePagesChange(path, 'change')); - server.watcher.on('unlink', (path) => handlePagesChange(path, 'unlink')); - }, - - configResolved(config) { - viteConfig = config; - }, - - config(_, env) { - // Set before the early return: pages/ may be created after the server starts. - isDev = env.command === 'serve'; - if (!existsSync(pagesDir)) { - return; - } - + config({ isDev }) { if (isDev) { return { optimizeDeps: SVELTE_OPTIMIZE_DEPS, @@ -193,7 +88,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu }, }; }, - }; + }); } function generateRendererModule(scan: PageScanResult, pagesDir: string): string { diff --git a/packages/void/src/pages/index.ts b/packages/void/src/pages/index.ts index 428c45828..c0e08d2ec 100644 --- a/packages/void/src/pages/index.ts +++ b/packages/void/src/pages/index.ts @@ -14,6 +14,7 @@ export type { export { resolveHead, renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from './head.ts'; export type { HeadDescriptor, HeadConfig } from './head.ts'; export { generateComponentManifest } from './manifest.ts'; +export { createPagesPlugin } from './plugin.ts'; export { resolveStaticPageDataBuildOptions } from './static-data.ts'; export type { StaticPageDataBuildOptions } from './static-data.ts'; export { serializePageData } from './serialize.ts'; diff --git a/packages/void/src/pages/plugin.ts b/packages/void/src/pages/plugin.ts new file mode 100644 index 000000000..ee6af2574 --- /dev/null +++ b/packages/void/src/pages/plugin.ts @@ -0,0 +1,149 @@ +import { existsSync } from 'node:fs'; +import { dirname } from 'pathslash'; +import { normalizePath, type Plugin, type ResolvedConfig, type UserConfig } from 'vite'; +import { scanPages } from './scan.ts'; +import { + resolveStaticPageDataBuildOptions, + type StaticPageDataBuildOptions, +} from './static-data.ts'; +import type { PageScanResult } from './types.ts'; + +const RENDERER_ID = 'virtual:void-pages-renderer'; +const CLIENT_ENTRY_ID = 'virtual:void-pages-client'; + +type PagesPluginContext = { + isDev: boolean; + scan: () => Promise; +}; + +type PagesPluginOptions = { + name: string; + root?: string; + generateRenderer: (scan: PageScanResult, isDev: boolean) => string; + generateClient: (scan: PageScanResult, staticPageData: StaticPageDataBuildOptions) => string; + config: (context: PagesPluginContext) => UserConfig | void | Promise; +}; + +/** Shared Vite lifecycle; adapters own their generated code and environment configuration. */ +export function createPagesPlugin(pagesDir: string, options: PagesPluginOptions): Plugin { + let pageScan: PageScanResult | null = null; + let isDev = false; + let viteConfig: ResolvedConfig | undefined; + const root = options.root ?? dirname(pagesDir); + const normalizedPagesDir = normalizePath(pagesDir); + let pendingRescan: Promise | null = null; + async function rescanPages(): Promise { + pageScan = existsSync(pagesDir) + ? await scanPages(root, { pagesDir, environment: isDev ? 'dev' : 'prod' }) + : null; + } + + function runRescan(): Promise { + pendingRescan ??= rescanPages().finally(() => { + pendingRescan = null; + }); + return pendingRescan; + } + + return { + name: options.name, + + async buildStart() { + await runRescan(); + }, + + resolveId: { + filter: { id: /^virtual:void-pages-(renderer|client)$/ }, + handler(id) { + return '\0' + id; + }, + }, + + load: { + // eslint-disable-next-line no-control-regex -- Vite virtual module null-byte prefix + filter: { id: /^\0virtual:void-pages-(renderer|client)$/ }, + async handler(id) { + if (isDev && pendingRescan) { + await pendingRescan; + } + if (!pageScan && existsSync(pagesDir)) { + await runRescan(); + } + if (id === '\0' + RENDERER_ID) { + if (!pageScan) { + return "export function renderPage() { throw new Error('No pages found'); }"; + } + return options.generateRenderer(pageScan, isDev); + } + if (id === '\0' + CLIENT_ENTRY_ID) { + if (!pageScan) { + return ''; + } + return options.generateClient( + pageScan, + resolveStaticPageDataBuildOptions(root, { publicDir: viteConfig?.publicDir }), + ); + } + }, + }, + + configureServer(server) { + async function handlePagesChange( + path: string, + event: 'add' | 'change' | 'unlink', + ): Promise { + const normalizedPath = normalizePath(path); + if ( + normalizedPath !== normalizedPagesDir && + !normalizedPath.startsWith(normalizedPagesDir + '/') + ) { + return; + } + if (event === 'change' && !normalizedPath.endsWith('.md')) { + return; + } + try { + await runRescan(); + } catch (error) { + // Keep the last good scan; the next change rescans. + server.config.logger.error(error instanceof Error ? error.message : String(error), { + error: error instanceof Error ? error : undefined, + timestamp: true, + }); + return; + } + for (const env of Object.values(server.environments)) { + for (const id of ['\0' + RENDERER_ID, '\0' + CLIENT_ENTRY_ID]) { + const mod = env.moduleGraph.getModuleById(id); + if (mod) { + env.moduleGraph.invalidateModule(mod); + } + } + } + } + + server.watcher.on('add', (path) => handlePagesChange(path, 'add')); + server.watcher.on('change', (path) => handlePagesChange(path, 'change')); + server.watcher.on('unlink', (path) => handlePagesChange(path, 'unlink')); + }, + + configResolved(config) { + viteConfig = config; + }, + + config(_, env) { + // Set before the early return: pages/ may be created after the server starts. + isDev = env.command === 'serve'; + if (!existsSync(pagesDir)) { + return; + } + return options.config({ + isDev, + async scan() { + await runRescan(); + return pageScan; + }, + }); + }, + }; +} diff --git a/packages/void/test/unit/pages-plugin-adapters.test.ts b/packages/void/test/unit/pages-plugin-adapters.test.ts new file mode 100644 index 000000000..69ae3c08d --- /dev/null +++ b/packages/void/test/unit/pages-plugin-adapters.test.ts @@ -0,0 +1,213 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import type { Plugin } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { pagesPlugin as reactPagesPlugin } from '../../../react/src/plugin-pages.ts'; +import { pagesPlugin as solidPagesPlugin } from '../../../solid/src/plugin-pages.ts'; +import { pagesPlugin as sveltePagesPlugin } from '../../../svelte/src/plugin-pages.ts'; +import { pagesPlugin as vuePagesPlugin } from '../../../vue/src/plugin-pages.ts'; + +type Hook = T | { handler: T; filter?: { id: RegExp } }; +type PagesPluginHooks = { + config: Hook<(config: object, env: { command: 'serve' | 'build' }) => unknown>; + configResolved: (config: { publicDir: string | false }) => void; + buildStart: Hook<() => Promise>; + resolveId: Hook<(id: string) => string | undefined>; + load: Hook<(id: string) => Promise>; + configureServer: (server: unknown) => void; +}; + +function handler(hook: Hook): T { + return typeof hook === 'object' && hook !== null && 'handler' in hook ? hook.handler : hook; +} + +const adapters = [ + { name: 'react', extension: 'tsx', create: reactPagesPlugin, scansBuild: true }, + { name: 'solid', extension: 'tsx', create: solidPagesPlugin, scansBuild: true }, + { name: 'svelte', extension: 'svelte', create: sveltePagesPlugin, scansBuild: false }, + { name: 'vue', extension: 'vue', create: vuePagesPlugin, scansBuild: false }, +]; + +describe.each(adapters)('$name Pages plugin contract', (adapter) => { + let root: string | undefined; + + afterEach(() => { + if (root) { + rmSync(root, { recursive: true, force: true }); + } + root = undefined; + }); + + function setup() { + root = mkdtempSync(join(tmpdir(), `void-${adapter.name}-pages-plugin-`)); + const pagesDir = join(root, '_source', '_views'); + const plugins = adapter.create(pagesDir, { + root, + viewTransitions: true, + prefetch: { hoverDelay: 123, cacheFor: '45s' }, + }); + const plugin = (Array.isArray(plugins) ? plugins : [plugins]).find( + (candidate: Plugin) => candidate.name === `void-${adapter.name}:pages`, + ) as unknown as PagesPluginHooks; + function write(file: string, source = ''): string { + const path = join(pagesDir, file); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, source); + return path; + } + return { plugin, pagesDir, write }; + } + + it.each([ + ['island only', undefined, false, false], + ['Markdown', 'about.md', false, false], + ['ordinary page', 'about.$ext', true, true], + ['dev-only page', 'debug.dev.$ext', false, true], + ['private directory', '_private/helper.$ext', false, true], + ['layout-prefixed page', 'layout.extra.$ext', true, false], + ['island-like page name', 'feature.island.demo.$ext', true, false], + ['foreign component', 'other.$foreign', true, false], + ])('preserves client build selection for %s', async (_, file, scanned, filenames) => { + const { plugin, write } = setup(); + write(`index.island.${adapter.extension}`); + if (file) { + write( + file + .replace('$ext', adapter.extension) + .replace('$foreign', adapter.name === 'vue' ? 'svelte' : 'vue'), + ); + } + + const config = await handler(plugin.config)({}, { command: 'build' }); + if (adapter.scansBuild ? scanned : filenames) { + const clientConfig = { + build: { + manifest: true, + rollupOptions: { input: { 'pages-client': 'virtual:void-pages-client' } }, + }, + }; + expect(config).toEqual( + adapter.name === 'vue' + ? { environments: { client: clientConfig } } + : adapter.name === 'react' + ? { ...clientConfig, resolve: { dedupe: ['react', 'react-dom'] } } + : clientConfig, + ); + } else { + expect(config).toBeUndefined(); + } + }); + + it('resolves only the Pages virtual module IDs', async () => { + const { plugin } = setup(); + const hook = plugin.resolveId as { filter: { id: RegExp } }; + for (const id of ['virtual:void-pages-renderer', 'virtual:void-pages-client']) { + expect(hook.filter.id.test(id)).toBe(true); + expect(handler(plugin.resolveId)(id)).toBe('\0' + id); + } + expect(hook.filter.id.test('virtual:void-islands-renderer')).toBe(false); + expect(hook.filter.id.test('virtual:void-pages-client-extra')).toBe(false); + await expect(handler(plugin.load)('\0virtual:unrelated')).resolves.toBeUndefined(); + }); + + it('handles a missing directory and lazily scans pages created after config', async () => { + const { plugin, write } = setup(); + expect(await handler(plugin.config)({}, { command: 'serve' })).toBeUndefined(); + expect(await handler(plugin.load)('\0virtual:void-pages-renderer')).toBe( + "export function renderPage() { throw new Error('No pages found'); }", + ); + expect(await handler(plugin.load)('\0virtual:void-pages-client')).toBe(''); + + write(`debug.dev.${adapter.extension}`); + write(`home.prod.${adapter.extension}`); + const renderer = await handler(plugin.load)('\0virtual:void-pages-renderer'); + expect(renderer).toContain(`debug.dev.${adapter.extension}`); + expect(renderer).not.toContain('home.prod'); + if (adapter.name === 'react') { + expect(renderer).toContain('/@react-refresh'); + } + }); + + it('passes client options and resolved publicDir into generated modules', async () => { + const { plugin, write } = setup(); + write(`index.${adapter.extension}`); + writeFileSync(join(root!, 'void.config.ts'), 'export default { output: "static" };'); + const publicDir = join(root!, 'custom-public'); + mkdirSync(publicDir); + writeFileSync(join(publicDir, '_redirects'), '/old /new 302'); + await handler(plugin.config)({}, { command: 'build' }); + plugin.configResolved({ publicDir }); + await handler(plugin.buildStart)(); + + const client = await handler(plugin.load)('\0virtual:void-pages-client'); + expect(client).toContain('staticPageData: true'); + expect(client).toContain('staticPageDataFastPath: false'); + expect(client).toContain('viewTransitions: true'); + expect(client).toContain('prefetchHoverDelay: 123'); + expect(client).toContain('parseCacheFor("45s")'); + + plugin.configResolved({ publicDir: false }); + expect(await handler(plugin.load)('\0virtual:void-pages-client')).toContain( + 'staticPageDataFastPath: true', + ); + }); + + it('rescans structural and Markdown changes, invalidating both modules in every environment', async () => { + const { plugin, pagesDir, write } = setup(); + write(`index.${adapter.extension}`); + await handler(plugin.config)({}, { command: 'serve' }); + await handler(plugin.buildStart)(); + const events = new Map Promise>(); + const moduleIds = ['\0virtual:void-pages-renderer', '\0virtual:void-pages-client']; + const invalidations = [vi.fn(), vi.fn()]; + plugin.configureServer({ + watcher: { + on(event: string, callback: (path: string) => Promise) { + return events.set(event, callback); + }, + }, + environments: Object.fromEntries( + ['client', 'void_worker'].map((name, index) => [ + name, + { + moduleGraph: { + getModuleById(id: string) { + return { id }; + }, + invalidateModule: invalidations[index], + }, + }, + ]), + ), + config: { logger: { error: vi.fn() } }, + }); + + const added = write(`added.${adapter.extension}`); + await events.get('add')!(join(pagesDir + '-other', 'outside.md')); + await events.get('change')!(added); + for (const invalidate of invalidations) { + expect(invalidate).not.toHaveBeenCalled(); + } + + await events.get('add')!(added); + for (const invalidate of invalidations) { + expect(invalidate.mock.calls.map(([module]) => module.id)).toEqual(moduleIds); + invalidate.mockClear(); + } + expect(await handler(plugin.load)(moduleIds[0])).toContain(`added.${adapter.extension}`); + expect(await handler(plugin.load)(moduleIds[1])).toContain(`added.${adapter.extension}`); + + const markdown = write('about.md', '# About'); + await events.get('change')!(markdown); + expect(await handler(plugin.load)(moduleIds[0])).toContain('about.md'); + for (const invalidate of invalidations) { + expect(invalidate).toHaveBeenCalledTimes(2); + } + + rmSync(added); + await events.get('unlink')!(added); + expect(await handler(plugin.load)(moduleIds[0])).not.toContain(`added.${adapter.extension}`); + expect(await handler(plugin.load)(moduleIds[1])).not.toContain(`added.${adapter.extension}`); + }); +}); diff --git a/packages/void/test/unit/pages-plugin.test.ts b/packages/void/test/unit/pages-plugin.test.ts new file mode 100644 index 000000000..98517791d --- /dev/null +++ b/packages/void/test/unit/pages-plugin.test.ts @@ -0,0 +1,138 @@ +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; +import { createPagesPlugin } from '../../src/pages/plugin.ts'; +import { scanPages } from '../../src/pages/scan.ts'; +import type { PageScanResult } from '../../src/pages/types.ts'; + +vi.mock('../../src/pages/scan.ts', () => ({ scanPages: vi.fn() })); + +type Hook = T | { handler: T }; +type PagesPluginHooks = { + buildStart: Hook<() => Promise>; + config: (config: object, env: { command: 'serve' | 'build' }) => unknown; + load: Hook<(id: string) => Promise>; + configureServer: (server: unknown) => void; +}; + +function handler(hook: Hook): T { + return typeof hook === 'object' && hook !== null && 'handler' in hook ? hook.handler : hook; +} + +function scanWithLayout(filePath: string): PageScanResult { + return { pages: [], layouts: [{ directory: '', filePath }], namedLayouts: [] }; +} + +function pendingScan() { + let resolve!: (scan: PageScanResult) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe('shared Pages plugin scan lifecycle', () => { + let root: string; + let pagesDir: string; + + beforeEach(() => { + vi.mocked(scanPages).mockReset(); + root = mkdtempSync(join(tmpdir(), 'void-pages-plugin-')); + pagesDir = join(root, 'pages'); + mkdirSync(pagesDir); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + function setup() { + return createPagesPlugin(pagesDir, { + name: 'test:pages', + generateRenderer(scan) { + return JSON.stringify(scan.layouts); + }, + generateClient(scan) { + return JSON.stringify(scan.layouts); + }, + config() {}, + }) as unknown as PagesPluginHooks; + } + + it('shares a pending scan between buildStart and simultaneous lazy module loads', async () => { + const pending = pendingScan(); + vi.mocked(scanPages).mockReturnValueOnce(pending.promise); + const plugin = setup(); + plugin.config({}, { command: 'serve' }); + + const build = handler(plugin.buildStart)(); + const renderer = handler(plugin.load)('\0virtual:void-pages-renderer'); + const client = handler(plugin.load)('\0virtual:void-pages-client'); + expect(scanPages).toHaveBeenCalledExactlyOnceWith(root, { pagesDir, environment: 'dev' }); + + pending.resolve(scanWithLayout('layout.tsx')); + await build; + await expect(renderer).resolves.toContain('layout.tsx'); + await expect(client).resolves.toContain('layout.tsx'); + expect(scanPages).toHaveBeenCalledTimes(1); + }); + + it('waits for a watcher rescan before serving either dev module', async () => { + vi.mocked(scanPages).mockResolvedValueOnce(scanWithLayout('before.tsx')); + const plugin = setup(); + plugin.config({}, { command: 'serve' }); + await handler(plugin.buildStart)(); + + const events = new Map Promise>(); + plugin.configureServer({ + watcher: { + on(event: string, callback: (path: string) => Promise) { + return events.set(event, callback); + }, + }, + environments: {}, + config: { logger: { error: vi.fn() } }, + }); + const pending = pendingScan(); + vi.mocked(scanPages).mockReturnValueOnce(pending.promise); + const change = events.get('add')!(join(pagesDir, 'added.tsx')); + const renderer = handler(plugin.load)('\0virtual:void-pages-renderer'); + const client = handler(plugin.load)('\0virtual:void-pages-client'); + + pending.resolve(scanWithLayout('after.tsx')); + await change; + await expect(renderer).resolves.toContain('after.tsx'); + await expect(client).resolves.toContain('after.tsx'); + expect(scanPages).toHaveBeenCalledTimes(2); + }); + + it('propagates build scan errors and allows a later lazy load to retry', async () => { + const failure = new Error('Invalid page layout'); + vi.mocked(scanPages).mockRejectedValueOnce(failure); + const plugin = setup(); + plugin.config({}, { command: 'build' }); + await expect(handler(plugin.buildStart)()).rejects.toBe(failure); + + vi.mocked(scanPages).mockResolvedValueOnce(scanWithLayout('recovered.tsx')); + await expect(handler(plugin.load)('\0virtual:void-pages-renderer')).resolves.toContain( + 'recovered.tsx', + ); + expect(scanPages).toHaveBeenLastCalledWith(root, { pagesDir, environment: 'prod' }); + expect(scanPages).toHaveBeenCalledTimes(2); + }); + + it('drops a previous scan when the pages directory is removed', async () => { + vi.mocked(scanPages).mockResolvedValueOnce(scanWithLayout('removed.tsx')); + const plugin = setup(); + await handler(plugin.buildStart)(); + rmSync(pagesDir, { recursive: true }); + await handler(plugin.buildStart)(); + + await expect(handler(plugin.load)('\0virtual:void-pages-client')).resolves.toBe(''); + await expect(handler(plugin.load)('\0virtual:void-pages-renderer')).resolves.not.toContain( + 'removed.tsx', + ); + expect(scanPages).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vue/src/plugin-pages.ts b/packages/vue/src/plugin-pages.ts index ea609ec2f..8f327d567 100644 --- a/packages/vue/src/plugin-pages.ts +++ b/packages/vue/src/plugin-pages.ts @@ -1,14 +1,8 @@ -import { existsSync, readdirSync } from 'node:fs'; -import { dirname } from 'pathslash'; -import { normalizePath, type Plugin, type ResolvedConfig, type ViteDevServer } from 'vite'; -import { - generateComponentManifest, - resolveStaticPageDataBuildOptions, - scanPages, -} from 'void/pages'; +import { readdirSync } from 'node:fs'; +import { normalizePath, type Plugin } from 'vite'; +import { createPagesPlugin, generateComponentManifest } from 'void/pages'; import type { PageScanResult, StaticPageDataBuildOptions } from 'void/pages'; -const RENDERER_ID = 'virtual:void-pages-renderer'; const CLIENT_ENTRY_ID = 'virtual:void-pages-client'; /** Runtime helper injected into generated SSR and client page bundles. */ @@ -119,35 +113,7 @@ function hasNonIslandPages(dir: string): boolean { } export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): ReadonlyArray { - let pageScan: PageScanResult | null = null; - let isDev = false; - let viteConfig: ResolvedConfig | undefined; - const root = options?.root ?? dirname(pagesDir); const normalizedPagesDir = normalizePath(pagesDir); - let pendingRescan: Promise | null = null; - const rescanPages = async () => { - pageScan = existsSync(pagesDir) - ? await scanPages(root, { pagesDir, environment: isDev ? 'dev' : 'prod' }) - : null; - }; - const runRescan = () => { - pendingRescan ??= rescanPages().finally(() => { - pendingRescan = null; - }); - return pendingRescan; - }; - - const invalidatePageModules = (server: ViteDevServer) => { - for (const env of Object.values(server.environments)) { - for (const id of ['\0' + RENDERER_ID, '\0' + CLIENT_ENTRY_ID]) { - const mod = env.moduleGraph.getModuleById(id); - if (mod) { - env.moduleGraph.invalidateModule(mod); - } - } - } - }; - return [ { name: 'void-vue:pages-transform', @@ -183,92 +149,22 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Rea }, }, }, - { + createPagesPlugin(pagesDir, { name: 'void-vue:pages', - - async buildStart() { - await runRescan(); - }, - - resolveId: { - filter: { id: /^virtual:void-pages-(renderer|client)$/ }, - handler(id) { - return '\0' + id; - }, - }, - - load: { - // eslint-disable-next-line no-control-regex -- Vite virtual module null-byte prefix - filter: { id: /^\0virtual:void-pages-(renderer|client)$/ }, - async handler(id) { - if (isDev && pendingRescan) { - await pendingRescan; - } - if (!pageScan && existsSync(pagesDir)) { - await runRescan(); - } - if (id === '\0' + RENDERER_ID) { - if (!pageScan) { - return "export function renderPage() { throw new Error('No pages found'); }"; - } - return generateRendererModule(pageScan, pagesDir); - } - if (id === '\0' + CLIENT_ENTRY_ID) { - if (!pageScan) { - return ''; - } - return generateClientEntry( - pageScan, - pagesDir, - options?.viewTransitions, - options?.prefetch, - resolveStaticPageDataBuildOptions(root, { publicDir: viteConfig?.publicDir }), - ); - } - }, + root: options?.root, + generateRenderer(scan) { + return generateRendererModule(scan, pagesDir); }, - - configureServer(server) { - const handlePagesChange = async (path: string, event: 'add' | 'change' | 'unlink') => { - const normalizedPath = normalizePath(path); - if ( - normalizedPath !== normalizedPagesDir && - !normalizedPath.startsWith(normalizedPagesDir + '/') - ) { - return; - } - if (event === 'change' && !normalizedPath.endsWith('.md')) { - return; - } - try { - await runRescan(); - } catch (error) { - // Keep the last good scan; the next change rescans. - server.config.logger.error(error instanceof Error ? error.message : String(error), { - error: error instanceof Error ? error : undefined, - timestamp: true, - }); - return; - } - invalidatePageModules(server); - }; - - server.watcher.on('add', (path) => handlePagesChange(path, 'add')); - server.watcher.on('change', (path) => handlePagesChange(path, 'change')); - server.watcher.on('unlink', (path) => handlePagesChange(path, 'unlink')); - }, - - configResolved(config) { - viteConfig = config; + generateClient(scan, staticPageData) { + return generateClientEntry( + scan, + pagesDir, + options?.viewTransitions, + options?.prefetch, + staticPageData, + ); }, - - config(_, env) { - // Set before the early return: pages/ may be created after the server starts. - isDev = env.command === 'serve'; - if (!existsSync(pagesDir)) { - return; - } - + config({ isDev }) { if (isDev) { return { optimizeDeps: { @@ -298,7 +194,7 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Rea }, }; }, - }, + }), ]; }