Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/client-host-prerender.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solidjs/vite-plugin': patch
---

Use the resolved client and server build directories when prerendering client-mode shells and serving preview requests. Preserve the SSR service for host build orchestrators such as Nitro, including client mode without server functions, while standalone client builds still produce only static output.
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -778,12 +778,13 @@ export default defineConfig({
graph's CSS inlined; deep links get the same shell (history-fallback
semantics). The generated client entry `render()`s (not hydrates) the app
into `document.body`.
- **Build**: `vite build` emits a purely static `dist/client` — the shell is
prerendered once through the built handler into `dist/client/index.html`,
with the hashed entry script and the entry graph's CSS links — deployable
to any static host. No server bundle remains unless `serverFunctions` is
enabled, in which case `dist/server` is kept and its `handleRequest`
serves the endpoint (pages stay static).
- **Build**: standalone `vite build` emits a static `dist/client`. The shell
is prerendered once through the built handler into `dist/client/index.html`,
with hashed scripts and CSS links, ready for a static host. The server bundle
is removed unless `serverFunctions` is enabled, in which case `dist/server`
is kept and its `handleRequest` serves the endpoint (pages stay static).
Hosts such as Nitro can relocate the client and server outputs; prerender
uses those directories and retains the server service for the host's build.
- **Transforms**: client code compiles exactly like a plain SPA today
(`generate: 'dom'`, non-hydratable); only the document shell goes through
the SSR transforms.
Expand Down
2 changes: 1 addition & 1 deletion examples/start-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "node test/run.mjs"
"test": "node test/run.mjs && node test/host-build.mjs"
},
"devDependencies": {
"vite": "^8.2.1",
Expand Down
154 changes: 154 additions & 0 deletions examples/start-client/test/cleanup-target.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createBuilder } from 'vite';
import solid from '@solidjs/vite-plugin';
import trash from 'trash';

const fixture = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
async function run(failure) {
const sandbox = fs.mkdtempSync(path.join(fixture, 'node_modules/cleanup-target-'));
const originalRemove = fs.rmSync;
let primaryError;
try {
const root = path.join(sandbox, 'project');
const clientDir = path.join(root, 'dist/client');
fs.mkdirSync(path.join(root, 'src'), { recursive: true });
fs.mkdirSync(clientDir, { recursive: true });
fs.writeFileSync(path.join(root, 'package.json'), '{"type":"module"}');
fs.writeFileSync(
path.join(root, 'src/App.tsx'),
'export default function App() { return null; }',
);
const sentinel = path.join(clientDir, 'asset.js');
fs.writeFileSync(sentinel, 'client asset');

if (failure === 'setup') throw new Error('Injected setup failure');

// Exercise the real prerender hook with completed environment builds. Its
// removal is observed without executing it, even for project-root targets.
const builder = await createBuilder({
root,
configFile: false,
plugins: [solid({ start: true })],
});
const serving = builder.config.plugins.find((plugin) => plugin.name === 'solid:ssr/setup');
const prerender = builder.config.plugins.find(
(plugin) => plugin.name === 'solid:start/prerender',
);
assert.ok(serving && prerender);
const warnings = [];
builder.config.logger.warn = (message) => warnings.push(message);
const removals = [];
fs.rmSync = (directory) => removals.push(directory);
syncBuiltinESMExports();
for (const [name, target, removable] of [
['project root', root, false],
['project ancestor', sandbox, false],
['outside root', path.join(sandbox, 'outside'), false],
['root prefix sibling', root + '-sibling', false],
['client directory', clientDir, false],
['client ancestor', path.dirname(clientDir), false],
['standalone server', path.join(root, 'dist/server'), true],
]) {
fs.mkdirSync(target, { recursive: true });
fs.writeFileSync(
path.join(target, 'server.js'),
failure === 'build'
? 'export function handleRequest() { throw new Error("Injected build failure"); }'
: 'export function handleRequest() { return new Response("<!DOCTYPE html><html></html>"); }',
);
serving.generateBundle.handler.call(
{ environment: builder.environments.client },
{ dir: clientDir },
{},
);
serving.generateBundle.handler.call(
{ environment: builder.environments.ssr },
{ dir: target },
{},
);
removals.length = 0;
warnings.length = 0;
await prerender.buildApp.handler({
config: builder.config,
environments: { client: { isBuilt: true }, ssr: { isBuilt: true } },
});
if (failure === 'assertion') assert.fail('Injected assertion failure');
assert.deepEqual(removals, removable ? [target] : [], `cleanup eligibility: ${name}`);
assert.deepEqual(
warnings,
removable
? []
: [
'Preserving SSR output at ' +
target +
': automatic cleanup requires a directory inside the project root that does not contain client output.',
],
'warning eligibility: ' + name,
);
const host = { name: 'fixture:host', buildApp() {} };
builder.config.plugins.push(host);
warnings.length = 0;
removals.length = 0;
await prerender.buildApp.handler({
config: builder.config,
environments: { client: { isBuilt: true }, ssr: { isBuilt: true } },
});
assert.deepEqual(warnings, [], 'host never warns: ' + name);
assert.deepEqual(removals, [], 'host output retained: ' + name);
builder.config.plugins.pop();
const functionsBuilder = await createBuilder({
root,
configFile: false,
customLogger: { ...builder.config.logger, warn: (message) => warnings.push(message) },
plugins: [solid({ start: true, serverFunctions: true })],
});
const functionsServing = functionsBuilder.config.plugins.find(
(plugin) => plugin.name === 'solid:ssr/setup',
);
functionsServing.generateBundle.handler.call(
{ environment: functionsBuilder.environments.client },
{ dir: clientDir },
{},
);
functionsServing.generateBundle.handler.call(
{ environment: functionsBuilder.environments.ssr },
{ dir: target },
{},
);
await functionsBuilder.config.plugins
.find((plugin) => plugin.name === 'solid:start/prerender')
.buildApp.handler({
config: functionsBuilder.config,
environments: { client: { isBuilt: true }, ssr: { isBuilt: true } },
});
assert.deepEqual(warnings, [], 'function output never warns: ' + name);
assert.deepEqual(removals, [], 'function output retained: ' + name);
assert.equal(fs.readFileSync(sentinel, 'utf8'), 'client asset');
console.log(`PASS cleanup target: ${name}`);
}
} catch (error) {
primaryError = error;
throw error;
} finally {
fs.rmSync = originalRemove;
syncBuiltinESMExports();
try {
await trash([sandbox], { glob: false });
assert.equal(fs.existsSync(sandbox), false, 'cleanup-target sandbox cleaned');
} catch (error) {
if (primaryError) {
throw new AggregateError([primaryError, error], 'Failed to clean cleanup-target sandbox');
}
throw error;
}
}
}
await run();
for (const failure of ['setup', 'build', 'assertion']) {
await assert.rejects(run(failure), new RegExp('Injected ' + failure + ' failure'));
console.log('PASS cleanup-target failure cleanup: ' + failure);
}
Loading