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
8 changes: 8 additions & 0 deletions .changeset/dev-manifest-no-debounce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@solidjs/vite-plugin': patch
---

Two dev fixes for the server function manifest, which the generated handler imports when another plugin owns the SSR environment (for example `@cloudflare/vite-plugin`):

- The manifest no longer waits a fixed second on each load (#427). It reloads each time a new server function module is transformed, so startup, restarts and the first call to a new function each waited 1,000 ms. The debounce is only needed for builds, so dev now returns the current list. Each new module still invalidates the manifest before anything can call it.
- A deleted `"use server"` file, or one that no longer declares server functions, is now removed from the manifest. Before, the manifest kept importing it, and every request failed with `Failed to load url` until the dev server restarted.
46 changes: 41 additions & 5 deletions src/server-functions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,13 @@ function invalidateModules(
}
}

function removeManifestRecord(
source: Set<string>,
target: string,
): ReturnType<typeof mergeManifestRecord> {
return { invalidPreload: source.delete(target), invalidated: [...source] };
}

/**
* The second parameter is internal wiring for the main plugin's
* `serverFunctions` option: the built-in dev middleware is only installed
Expand Down Expand Up @@ -567,6 +574,19 @@ export function serverFunctions(
return hashIndex.get(functionId.split('-')[1]!);
}

// Dev only: drops a module that was deleted or no longer declares server
// functions, so the manifest stops importing it. A build transforms each
// module once, so its manifest never needs this.
function forgetModule(id: string): void {
if (isBuild || !manifest.modules.server.has(id)) return;
for (const [functionId, record] of manifest.clientFunctions) {
if (record.module === id) manifest.clientFunctions.delete(functionId);
}
hashIndexSize = -1;
const result = removeManifestRecord(manifest.modules.server, id);
invalidateModules(currentServer, result, manifestId);
}

function moduleDevUrl(entry: string): string {
const relative = path.relative(root, entry).split(path.sep).join('/');
return relative.startsWith('..') ? '/@fs/' + entry : '/' + relative;
Expand Down Expand Up @@ -703,7 +723,10 @@ export function serverFunctions(

// The directive has to appear literally, so anything without the
// substring can skip the native parse entirely.
if (!code.includes(directive)) return null;
if (!code.includes(directive)) {
forgetModule(id);
return null;
}

const result = await compile(id, code, {
...(mode === 'server' ? serverOptions : clientOptions),
Expand All @@ -713,7 +736,10 @@ export function serverFunctions(
sourceMap: !tsrx || !!internal.tsrxSourceMap,
});

if (!result.valid) return null;
if (!result.valid) {
forgetModule(id);
return null;
}

// The client compile is the authority on what the browser can dispatch:
// record every reference it emitted, by wire id, for the persisted
Expand Down Expand Up @@ -796,6 +822,9 @@ export function serverFunctions(
configureServer(server) {
currentServer = server;
},
watchChange(id, change) {
if (change.event === 'delete') forgetModule(id);
},
writeBundle() {
// Same client-build detection as the main plugin: builder-mode builds
// run both environments in one process, so prefer the per-environment
Expand Down Expand Up @@ -830,9 +859,16 @@ export function serverFunctions(
manifest.modules.server.add(entry);
}
}
const current = new Debouncer(() =>
[...manifest.modules[mode]].map((entry) => `import ${JSON.stringify(entry)};`).join('\n'),
);
const source = () =>
[...manifest.modules[mode]]
.map((entry) => `import ${JSON.stringify(entry)};`)
.join('\n');
// In dev, each transform that registers a new module invalidates
// the manifest, and a function's module is always transformed
// before anything can call it. So the current list is enough, and
// waiting would only delay every load.
if (!isBuild) return source();
const current = new Debouncer(source);
preload[mode] = current;
const result = await current.promise.reference;
return result;
Expand Down
99 changes: 99 additions & 0 deletions src/server-functions/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { mkdtemp, mkdir, realpath, rm, symlink, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer, type ViteDevServer } from 'vite';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { serverFunctions } from './index.js';

const MANIFEST_ID = 'virtual:solid-server-function-manifest';

// solidjs/vite-plugin-solid#427: a host that owns the SSR environment
// imports the manifest in dev. Its load must not wait the build debounce.
describe('server function manifest under vite dev', () => {
let root: string;
let server: ViteDevServer;

async function loadManifest() {
const started = performance.now();
const result = await server.environments.ssr.transformRequest(MANIFEST_ID);
return { code: result?.code ?? '', elapsed: performance.now() - started };
}

beforeAll(async () => {
root = await realpath(await mkdtemp(path.join(tmpdir(), 'solid-vite-plugin-manifest-')));
await symlink(
fileURLToPath(new URL('../../node_modules', import.meta.url)),
path.join(root, 'node_modules'),
'dir',
);
await mkdir(path.join(root, 'src'));
await writeFile(
path.join(root, 'src/greet.ts'),
'"use server";\nexport async function greet(name: string) {\n return `hello ${name}`;\n}\n',
);
await writeFile(
path.join(root, 'src/shout.ts'),
'"use server";\nexport async function shout(name: string) {\n return name.toUpperCase();\n}\n',
);
server = await createServer({
root,
configFile: false,
appType: 'custom',
logLevel: 'silent',
server: { middlewareMode: true, hmr: false, watch: null },
optimizeDeps: { noDiscovery: true, include: [] },
plugins: [serverFunctions()],
});
});

afterAll(async () => {
await server?.close();
await rm(root, { recursive: true, force: true });
});

it('loads the current list without waiting, and picks up new modules', async () => {
const empty = await loadManifest();
expect(empty.code).not.toContain('/src/greet.ts');
expect(empty.elapsed).toBeLessThan(500);

await server.environments.client.transformRequest('/src/greet.ts');
const first = await loadManifest();
expect(first.code).toContain('"/src/greet.ts"');
expect(first.elapsed).toBeLessThan(500);

await server.environments.client.transformRequest('/src/shout.ts');
const second = await loadManifest();
expect(second.code).toContain('"/src/greet.ts"');
expect(second.code).toContain('"/src/shout.ts"');
expect(second.elapsed).toBeLessThan(500);
});

it('drops a deleted module', async () => {
const file = path.join(root, 'src/shout.ts');
await unlink(file);
// What Vite's watcher does on unlink.
await Promise.all(
Object.values(server.environments).map((environment) =>
environment.pluginContainer.watchChange(file, { event: 'delete' }),
),
);
const { code } = await loadManifest();
expect(code).toContain('"/src/greet.ts"');
expect(code).not.toContain('/src/shout.ts');
});

it('drops a module that no longer declares server functions', async () => {
await writeFile(
path.join(root, 'src/greet.ts'),
'export const greet = (name: string) => name;\n',
);
const module = server.environments.client.moduleGraph.getModuleById(
path.join(root, 'src/greet.ts'),
);
if (module) server.environments.client.moduleGraph.invalidateModule(module);
await server.environments.client.transformRequest('/src/greet.ts');
const { code } = await loadManifest();
expect(code).not.toContain('/src/greet.ts');
});
});
Loading