diff --git a/.changeset/ssr-inline-seroval.md b/.changeset/ssr-inline-seroval.md new file mode 100644 index 0000000..da43e2e --- /dev/null +++ b/.changeset/ssr-inline-seroval.md @@ -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 `` 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. diff --git a/examples/start-ssr/package.json b/examples/start-ssr/package.json index 866607b..fb275b1 100644 --- a/examples/start-ssr/package.json +++ b/examples/start-ssr/package.json @@ -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", diff --git a/examples/start-ssr/test/ssr-inline.mjs b/examples/start-ssr/test/ssr-inline.mjs new file mode 100644 index 0000000..e3d0489 --- /dev/null +++ b/examples/start-ssr/test/ssr-inline.mjs @@ -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); +} diff --git a/src/index.ts b/src/index.ts index b752f40..d3c5eb8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1592,12 +1592,25 @@ export default function solidPlugin(options: Partial = {}): 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', ]; } }