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
5 changes: 5 additions & 0 deletions .changeset/ssr-inline-seroval.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solidjs/vite-plugin': patch
---

Under `vite dev` the SSR environment now inlines `seroval` and `seroval-plugins` next to `solid-js` and `@solidjs/web`. Both ship a `dist/dev` build behind the `development` condition, and `@solidjs/web` imports both. Left external, `seroval-plugins` loaded through Node and its own `import "seroval"` resolved to the prod copy, while the inlined `@solidjs/web` got the runner's dev copy. Seroval detects streams with `instanceof Stream`, so the `Stream` that `ReadableStreamPlugin` builds from one copy was rejected by the other copy's serializer. This broke server components (`serverFunctions: { components: true }`) in dev when a component resolved after the shell flush (for example after an `await` in the server function): its `sc:live` ReadableStream failed with "Seroval caught an error during the parsing process … cannot be parsed/serialized", the `<Loading>` boundary contained the error, and the component only rendered on the client. Builds were unaffected because they bundle a single copy. A host that sets `noExternal: true` and Vitest projects are left alone, as before.
2 changes: 1 addition & 1 deletion examples/start-ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"dev": "vite",
"build": "vite build",
"serve": "NODE_ENV=production node server.js",
"test": "node test/run.mjs && node test/http-bridge.mjs && node test/components-warning.mjs && node test/webworker-warning.mjs && node test/dedupe.mjs"
"test": "node test/run.mjs && node test/http-bridge.mjs && node test/components-warning.mjs && node test/webworker-warning.mjs && node test/dedupe.mjs && node test/ssr-inline.mjs"
},
"devDependencies": {
"jsdom": "^26.1.0",
Expand Down
89 changes: 89 additions & 0 deletions examples/start-ssr/test/ssr-inline.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Config-level test for the dev SSR inline list (`environments.ssr.resolve.noExternal`):
// - under `serve`, `solid-js`, `@solidjs/web`, `seroval` and
// `seroval-plugins` are inlined. Each ships a `dist/dev` build behind
// the `development` condition; left external, Node resolves their own
// imports without it and a second (prod) copy loads beside the runner's
// dev copy. For seroval that breaks `instanceof Stream`, so a server
// component's `sc:live` ReadableStream fails to serialize
// ("cannot be parsed/serialized") when it lands after the shell flush,
// - host entries are kept alongside,
// - a host that set `noExternal: true` is left alone,
// - under `build` none of them are added (dev-only fix; the build bundles
// one copy of everything).
//
// Pure resolveConfig — no dev server, no browser. Requires the plugin built
// (pnpm build at the repo root). Usage: node test/ssr-inline.mjs

import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { resolveConfig } from 'vite';
import solidPlugin from '@solidjs/vite-plugin';

const exampleDir = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const INLINED = ['solid-js', '@solidjs/web', 'seroval', 'seroval-plugins'];

const results = [];
function record(name, ok, detail = '') {
results.push({ name, ok, detail });
console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${detail}` : ''}`);
}

async function ssrNoExternal(command, ssrResolve) {
const config = await resolveConfig(
{
root: exampleDir,
configFile: false,
logLevel: 'error',
...(ssrResolve ? { environments: { ssr: { resolve: ssrResolve } } } : {}),
plugins: [solidPlugin({ ssr: true })],
},
command,
);
// A bare `build` resolve may carry no ssr environment at all.
return config.environments.ssr?.resolve.noExternal;
}

const show = (v) => (Array.isArray(v) ? v.map(String).join(', ') : String(v));

{
const noExternal = await ssrNoExternal('serve');
record(
'serve: solid-js, @solidjs/web, seroval and seroval-plugins inlined in ssr',
Array.isArray(noExternal) && INLINED.every((pkg) => noExternal.includes(pkg)),
show(noExternal),
);
}

{
const noExternal = await ssrNoExternal('serve', { noExternal: ['host-pkg'] });
record(
'serve: host noExternal entries kept alongside',
Array.isArray(noExternal) &&
noExternal.includes('host-pkg') &&
INLINED.every((pkg) => noExternal.includes(pkg)),
show(noExternal),
);
}

{
const noExternal = await ssrNoExternal('serve', { noExternal: true });
record('serve: host noExternal: true left alone', noExternal === true, show(noExternal));
}

{
const noExternal = await ssrNoExternal('build');
record(
'build: seroval and seroval-plugins not added (dev-only fix)',
!Array.isArray(noExternal) ||
(!noExternal.includes('seroval') && !noExternal.includes('seroval-plugins')),
show(noExternal),
);
}

const failed = results.filter((r) => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} assertions passed`);
if (failed.length) {
console.log('\nFailures:');
for (const f of failed) console.log(` ${f.name} — ${f.detail}`);
process.exit(1);
}
13 changes: 13 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1592,12 +1592,25 @@ export default function solidPlugin(options: Partial<Options> = {}): Plugin[] {
// reach the same copy. Vitest projects manage their own inlining
// (`test.server.deps` above) and are left alone, as is a host that
// set `noExternal: true` (everything is inlined already).
//
// `seroval` and `seroval-plugins` split the same way: both ship a
// `dist/dev` build behind `development`, and `@solidjs/web` imports
// both. Left external, `seroval-plugins` loads through Node and its
// own `import "seroval"` lands on the prod copy, while the inlined
// `@solidjs/web` gets the runner's dev copy. Seroval tells a stream
// apart with `instanceof Stream`, so the `Stream` that
// `ReadableStreamPlugin` builds from one copy is rejected by the
// other's serializer ("cannot be parsed/serialized"). A server
// component that lands after the shell flushes hits this: its
// `sc:live` channel is a ReadableStream serialized into the document.
if (!isTestMode && config.resolve.noExternal !== true) {
const noExternal = config.resolve.noExternal;
config.resolve.noExternal = [
...(Array.isArray(noExternal) ? noExternal : noExternal ? [noExternal] : []),
'solid-js',
'@solidjs/web',
'seroval',
'seroval-plugins',
];
}
}
Expand Down
Loading