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
11 changes: 10 additions & 1 deletion packages/react/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,16 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu
if (event === 'change' && !normalizedPath.endsWith('.md')) {
return;
}
await runRescan();
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);
};

Expand Down
46 changes: 43 additions & 3 deletions packages/react/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vite-plus/test';
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
import { pagesPlugin } from '../../src/plugin-pages.ts';

const TMP = join(import.meta.dirname, '__tmp_pages_plugin__');
Expand Down Expand Up @@ -45,7 +45,7 @@ function writePage(relativePath: string, source: string): void {
writeFileSync(full, source);
}

function setupWatcher(plugin: PagesPluginHooks) {
function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateModule = vi.fn()) {
const handlers = new Map<string, Array<(path: string) => Promise<void> | void>>();
plugin.configureServer?.({
watcher: {
Expand All @@ -55,7 +55,10 @@ function setupWatcher(plugin: PagesPluginHooks) {
handlers.set(event, eventHandlers);
},
},
environments: {},
environments: {
client: { moduleGraph: { getModuleById: (id: string) => ({ id }), invalidateModule } },
},
config: { logger: { error: logError } },
});
return handlers;
}
Expand Down Expand Up @@ -104,4 +107,41 @@ describe('pagesPlugin dev config', () => {

expect(renderer).toContain('"test": () => import');
});

it('logs a page scan error from the watcher and keeps the last scan', async () => {
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.buildStart).call({});
const logError = vi.fn();
const invalidateModule = vi.fn();
const handlers = setupWatcher(plugin, logError, invalidateModule);
const emitAdd = async (relativePath: string) => {
for (const handler of handlers.get('add') ?? []) {
await expect(handler(join(PAGES_DIR, relativePath))).resolves.toBeUndefined();
}
};

writePage('about.md', '---\nlayout: docs\n---\n# About\n');
await emitAdd('about.md');

expect(logError).toHaveBeenCalledTimes(1);
expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'");
expect(invalidateModule).not.toHaveBeenCalled();
const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
expect(renderer).toContain('"index": () => import');
expect(renderer).not.toContain('"about"');

writePage(
'_layouts/docs.tsx',
'export default function Docs({ children }) { return <main>{children}</main>; }\n',
);
await emitAdd('_layouts/docs.tsx');

expect(logError).toHaveBeenCalledTimes(1);
expect(invalidateModule).toHaveBeenCalled();
const recovered = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
expect(recovered).toContain('"about"');
});
});
11 changes: 10 additions & 1 deletion packages/solid/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,16 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu
if (event === 'change' && !normalizedPath.endsWith('.md')) {
return;
}
await runRescan();
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);
};

Expand Down
46 changes: 43 additions & 3 deletions packages/solid/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vite-plus/test';
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
import { pagesPlugin } from '../../src/plugin-pages.ts';

const TMP = join(import.meta.dirname, '__tmp_pages_plugin__');
Expand Down Expand Up @@ -36,7 +36,7 @@ function writePage(relativePath: string, source: string): void {
writeFileSync(full, source);
}

function setupWatcher(plugin: PagesPluginHooks) {
function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateModule = vi.fn()) {
const handlers = new Map<string, Array<(path: string) => Promise<void> | void>>();
plugin.configureServer?.({
watcher: {
Expand All @@ -46,7 +46,10 @@ function setupWatcher(plugin: PagesPluginHooks) {
handlers.set(event, eventHandlers);
},
},
environments: {},
environments: {
client: { moduleGraph: { getModuleById: (id: string) => ({ id }), invalidateModule } },
},
config: { logger: { error: logError } },
});
return handlers;
}
Expand Down Expand Up @@ -74,4 +77,41 @@ describe('pagesPlugin dev HMR', () => {

expect(renderer).toContain('"test": () => import');
});

it('logs a page scan error from the watcher and keeps the last scan', async () => {
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.buildStart).call({});
const logError = vi.fn();
const invalidateModule = vi.fn();
const handlers = setupWatcher(plugin, logError, invalidateModule);
const emitAdd = async (relativePath: string) => {
for (const handler of handlers.get('add') ?? []) {
await expect(handler(join(PAGES_DIR, relativePath))).resolves.toBeUndefined();
}
};

writePage('about.md', '---\nlayout: docs\n---\n# About\n');
await emitAdd('about.md');

expect(logError).toHaveBeenCalledTimes(1);
expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'");
expect(invalidateModule).not.toHaveBeenCalled();
const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
expect(renderer).toContain('"index": () => import');
expect(renderer).not.toContain('"about"');

writePage(
'_layouts/docs.tsx',
'export default function Docs(props) { return <main>{props.children}</main>; }\n',
);
await emitAdd('_layouts/docs.tsx');

expect(logError).toHaveBeenCalledTimes(1);
expect(invalidateModule).toHaveBeenCalled();
const recovered = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
expect(recovered).toContain('"about"');
});
});
11 changes: 10 additions & 1 deletion packages/svelte/src/plugin-pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,16 @@ export function pagesPlugin(pagesDir: string, options?: PagesPluginOptions): Plu
if (event === 'change' && !normalizedPath.endsWith('.md')) {
return;
}
await runRescan();
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);
};

Expand Down
46 changes: 43 additions & 3 deletions packages/svelte/test/unit/plugin-pages.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vite-plus/test';
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
import { pagesPlugin } from '../../src/plugin-pages.ts';

const TMP = join(import.meta.dirname, '__tmp_pages_plugin__');
Expand Down Expand Up @@ -36,7 +36,7 @@ function writePage(relativePath: string, source: string): void {
writeFileSync(full, source);
}

function setupWatcher(plugin: PagesPluginHooks) {
function setupWatcher(plugin: PagesPluginHooks, logError = vi.fn(), invalidateModule = vi.fn()) {
const handlers = new Map<string, Array<(path: string) => Promise<void> | void>>();
plugin.configureServer?.({
watcher: {
Expand All @@ -46,7 +46,10 @@ function setupWatcher(plugin: PagesPluginHooks) {
handlers.set(event, eventHandlers);
},
},
environments: {},
environments: {
client: { moduleGraph: { getModuleById: (id: string) => ({ id }), invalidateModule } },
},
config: { logger: { error: logError } },
});
return handlers;
}
Expand Down Expand Up @@ -75,6 +78,43 @@ describe('pagesPlugin dev HMR', () => {
expect(renderer).toContain('"test": () => import');
});

it('logs a page scan error from the watcher and keeps the last scan', async () => {
writePage('index.svelte', '<h1>Home</h1>\n');

const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks;
getHookHandler(plugin.config)({}, { command: 'serve' });
await getHookHandler(plugin.buildStart).call({});
const logError = vi.fn();
const invalidateModule = vi.fn();
const handlers = setupWatcher(plugin, logError, invalidateModule);
const emitAdd = async (relativePath: string) => {
for (const handler of handlers.get('add') ?? []) {
await expect(handler(join(PAGES_DIR, relativePath))).resolves.toBeUndefined();
}
};

writePage('about.md', '---\nlayout: docs\n---\n# About\n');
await emitAdd('about.md');

expect(logError).toHaveBeenCalledTimes(1);
expect(logError.mock.calls[0]?.[0]).toContain("specifies layout 'docs'");
expect(invalidateModule).not.toHaveBeenCalled();
const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
expect(renderer).toContain('"index": () => import');
expect(renderer).not.toContain('"about"');

writePage(
'_layouts/docs.svelte',
'<script>let { children } = $props();</script>\n<main>{@render children()}</main>\n',
);
await emitAdd('_layouts/docs.svelte');

expect(logError).toHaveBeenCalledTimes(1);
expect(invalidateModule).toHaveBeenCalled();
const recovered = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer');
expect(recovered).toContain('"about"');
});

it('excludes @void/svelte from the void worker optimizer in dev', () => {
writePage('index.svelte', '<h1>Home</h1>\n');

Expand Down
29 changes: 19 additions & 10 deletions packages/void/src/pages/islands-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,16 +327,25 @@ export function watchPagesForRescan(
}
debounceTimer = setTimeout(() => {
debounceTimer = null;
pending = scanPages(root, { pagesDir }).then((pageScan) => {
onRescan(pageScan);
for (const env of Object.values(server.environments)) {
const mod = env.moduleGraph.getModuleById('\0' + ISLANDS_RENDERER_ID);
if (mod) {
env.moduleGraph.invalidateModule(mod);
}
}
pending = null;
});
pending = scanPages(root, { pagesDir })
.then(
(pageScan) => {
onRescan(pageScan);
for (const env of Object.values(server.environments)) {
const mod = env.moduleGraph.getModuleById('\0' + ISLANDS_RENDERER_ID);
if (mod) {
env.moduleGraph.invalidateModule(mod);
}
}
},
() => {
// The adapter pages plugin runs the same scan on the same events and
// logs its error. Keep the last good scan until the next change.
},
)
.finally(() => {
pending = null;
});
}, 50);
};

Expand Down
44 changes: 19 additions & 25 deletions packages/void/src/pages/navigation-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,8 +293,14 @@ export async function createPageNavigationEngine(
}

for (const [key, group] of Object.entries(pageData.deferredKeys) as Array<[string, string]>) {
const ref = adapter.createDeferred();
const value = resolved ? adapter.resolveDeferred(ref, pageData.props[key]) : ref;
let value = adapter.createDeferred();
if (resolved) {
const error = pageData.deferredErrors?.[group];
value =
error === undefined
? adapter.resolveDeferred(value, pageData.props[key])
: adapter.rejectDeferred(value, error);
}
this.keyRefs[key] = value;
this.props[key] = value;
(this.groups[group] ??= []).push(key);
Expand Down Expand Up @@ -340,20 +346,7 @@ export async function createPageNavigationEngine(
}

if (data !== undefined) {
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
let resolvedAny = false;
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
if (this.keyRefs[key]) {
this.resolveKey(key, value);
resolvedAny = true;
}
}
if (!resolvedAny) {
this.resolveGroup(group, data);
}
} else {
this.resolveGroup(group, data);
}
this.resolveGroup(group, data);
} else if (error !== undefined) {
this.rejectGroup(group, typeof error === 'string' ? error : String(error));
}
Expand Down Expand Up @@ -388,7 +381,6 @@ export async function createPageNavigationEngine(

private retainOnly(keys: Set<string>, props: Record<string, unknown>): void {
const keyRefs: Record<string, unknown> = {};
const groups: Record<string, Array<string>> = {};
for (const [key, ref] of Object.entries(this.keyRefs)) {
if (keys.has(key)) {
keyRefs[key] = ref;
Expand All @@ -397,22 +389,24 @@ export async function createPageNavigationEngine(
adapter.rejectDeferred(ref, 'Navigation disposed before deferred props resolved');
}
}
for (const [group, groupKeys] of Object.entries(this.groups)) {
const preservedGroupKeys = groupKeys.filter((key) => keys.has(key));
if (preservedGroupKeys.length > 0) {
groups[group] = preservedGroupKeys;
}
}
// Groups keep every key so a group resolves with its original shape.
// Keys dropped from keyRefs are skipped when the group settles.
this.props = props;
this.keyRefs = keyRefs;
this.groups = groups;
adapter.onDeferredUpdate?.(this.props);
}

/** A one-key group takes the raw value; a larger group takes each key from the object. */
private resolveGroup(group: string, data: unknown): void {
const keys = this.groups[group] ?? (this.keyRefs[group] ? [group] : []);
if (keys.length === 1) {
this.resolveKey(keys[0]!, data);
return;
}
const values =
typeof data === 'object' && data !== null ? (data as Record<string, unknown>) : {};
for (const key of keys) {
this.resolveKey(key, data);
this.resolveKey(key, values[key]);
}
}

Expand Down
Loading
Loading